なぜ Rork + React Native に App Intents をそのまま書けないのか
ここで一度立ち止まり、技術的な前提を整理します。Rork は内部的に Expo / React Native を使って iOS アプリをビルドしていますが、App Intents API は Swift 固有で、Objective-C からすら呼べない完全な Swift-only API です。React Native のブリッジは通常 Objective-C を介すため、App Intents を直接 React 側から叩く経路はありません。
したがって選択肢は実質的に次のどちらかになります。
Expo Config Plugin 経由で iOS 側の Swift ファイルを差し込む (現実的)
Expo Modules API を使って Swift モジュールを書く (カプセル化したいなら有効)
筆者は前者の Config Plugin アプローチを推奨します。理由は App Intents が「アプリ全体のスキーマ」として振る舞うため、特定モジュールの中にカプセル化するより、プロジェクト直下に素直に置いた方が Apple のビルドシステム(Xcode)と相性が良いためです。具体的には、Xcode が AppIntents.framework を認識して AppShortcuts を自動登録する仕組みが、Expo Modules のサブプロジェクトに置くと発火しないケースを経験しました。
React Native の内部構造を先に把握したい方は、React Native × Expo アーキテクチャ完全解説 で全体像を押さえてから戻ってくると、以降の手順が頭に入りやすくなります。
実装の全体像 — 3層に分けて考える
App Intents を導入する作業は、次の3層に分けるとシンプルに整理できます。
スキーマ層 : AppShortcut・AppEntity・AppIntent の定義(Swift)
ブリッジ層 : React Native 側と Swift 側でデータを受け渡す経路(Expo Config Plugin + URLSchema or Deep Link)
体験層 : Donations、Focus Filter、iOS 18 の Control Center ウィジェット
以下ではこの3層を順に実装していきます。コード例はすべて私が本番アプリで動かしている構成をベースにしており、そのままコピペしても動きますが、Bundle ID と Team ID は自分の値に置き換えてください。
Step 1: Expo Config Plugin の骨組みを作る
まず Config Plugin を置くディレクトリを作ります。Rork プロジェクトのルートで次のように配置してください。
my-rork-app/
├── plugins/
│ └── with-app-intents/
│ ├── index.js
│ ├── AppIntents/
│ │ ├── MyAppShortcuts.swift
│ │ ├── LogWeightIntent.swift
│ │ └── WeightEntry.swift
│ └── PrivacyInfo.xcprivacy
├── app.json
└── package.json
plugins/with-app-intents/index.js は Expo Config Plugin の本体です。次のように書きます。
// plugins/with-app-intents/index.js
const { withXcodeProject , withDangerousMod } = require ( "@expo/config-plugins" );
const fs = require ( "fs" );
const path = require ( "path" );
const SWIFT_FILES = [
"MyAppShortcuts.swift" ,
"LogWeightIntent.swift" ,
"WeightEntry.swift" ,
];
/**
* App Intents 用の Swift ファイルを iOS プロジェクトにコピーし、
* Xcode のビルドフェーズに追加する Config Plugin。
*
* なぜ withDangerousMod + withXcodeProject の2段構成か:
* withDangerousMod でファイルをコピーし、withXcodeProject で
* Xcode プロジェクトの参照を更新する。
* どちらか一方だと「ファイルは存在するが Xcode が見つけてくれない」
* 状態になり、ビルド時に AppShortcuts が自動登録されない。
*/
const withAppIntents = ( config ) => {
// ① Swift ファイルを iOS プロジェクト直下にコピー
config = withDangerousMod (config, [
"ios" ,
async ( cfg ) => {
const srcDir = path. join (__dirname, "AppIntents" );
const dstDir = path. join (cfg.modRequest.platformProjectRoot, "AppIntents" );
if ( ! fs. existsSync (dstDir)) fs. mkdirSync (dstDir, { recursive: true });
for ( const f of SWIFT_FILES ) {
fs. copyFileSync (path. join (srcDir, f), path. join (dstDir, f));
}
return cfg;
},
]);
// ② Xcode プロジェクトにファイル参照を追加
config = withXcodeProject (config, async ( cfg ) => {
const project = cfg.modResults;
const groupName = "AppIntents" ;
// 既存の AppIntents グループを検索、なければ作成
let group = project. pbxGroupByName (groupName);
if ( ! group) {
const projectName = cfg.modRequest.projectName;
group = project. addPbxGroup ( SWIFT_FILES , groupName, groupName).pbxGroup;
const mainGroup = project. getFirstProject ().firstProject.mainGroup;
project. addToPbxGroup (group, mainGroup);
}
// ソースビルドフェーズに Swift ファイルを追加
for ( const f of SWIFT_FILES ) {
project. addSourceFile (
`AppIntents/${ f }` ,
{ target: project. getFirstTarget ().uuid },
);
}
return cfg;
});
return config;
};
module . exports = withAppIntents;
なぜこの2段構成なのか : withDangerousMod はファイルシステム操作を直接行える強力なフックですが、Xcode プロジェクトファイル(project.pbxproj)を触るには向きません。一方 withXcodeProject は xcode npm パッケージ経由で .pbxproj を安全に編集できます。Config Plugin 初心者がよく失敗するのは「ファイルはコピーしたが Xcode に追加していない」パターンで、ビルドは通るが AppIntents が iOS に認識されないという症状が出ます。
app.json で Plugin を有効化します。
{
"expo" : {
"name" : "MyRorkApp" ,
"slug" : "my-rork-app" ,
"plugins" : [
"./plugins/with-app-intents"
]
}
}
Step 2: AppShortcut・AppEntity・AppIntent を書く
ここが App Intents 設計の核心です。3つの Swift ファイルを順に書いていきます。扱うのは「体重を記録するアプリ」を想定したサンプルですが、ロジックは読書記録アプリでも水分摂取アプリでも同じです。
WeightEntry.swift — AppEntity の定義
AppEntity は Siri や Shortcuts が扱う「名詞」です。ユーザーのデータ1件を表します。
// plugins/with-app-intents/AppIntents/WeightEntry.swift
import AppIntents
import Foundation
struct WeightEntry : AppEntity {
let id: String
let date: Date
let weightKg: Double
let note: String ?
static var typeDisplayRepresentation: TypeDisplayRepresentation =
TypeDisplayRepresentation ( name : "体重記録" )
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation (
title : " \( weightKg ) kg" ,
subtitle : " \( date. formatted ( date : . abbreviated , time : . shortened ) ) "
)
}
static var defaultQuery = WeightEntryQuery ()
}
/// Siri が「最近の体重記録を見せて」と言われたときに呼ばれるクエリ。
struct WeightEntryQuery : EntityQuery {
func entities ( for identifiers: [ String ]) async throws -> [WeightEntry] {
// 実際は SharedContainer (App Group) 経由で React Native 側の
// データソースを読み取る
return SharedStorage.shared. fetchEntries ( ids : identifiers)
}
func suggestedEntities () async throws -> [WeightEntry] {
// 直近7件を Siri に候補として渡す
return SharedStorage.shared. recentEntries ( limit : 7 )
}
}
LogWeightIntent.swift — AppIntent の定義
AppIntent は「動詞」です。ユーザーの何らかの意図(Intent)を受けて処理を実行します。
// plugins/with-app-intents/AppIntents/LogWeightIntent.swift
import AppIntents
import Foundation
struct LogWeightIntent : AppIntent {
static var title: LocalizedStringResource = "体重を記録する"
static var description = IntentDescription ( "今日の体重を記録します" )
@Parameter (title : "体重 (kg)" , default: 65.0 )
var weightKg: Double
@Parameter (title : "メモ" )
var note: String ?
/// Shortcuts アプリから実行されたときに呼ばれる。
/// perform() は非同期なので、重い処理(AI 推論など)も書ける。
func perform () async throws -> some IntentResult & ProvidesDialog & ReturnsValue<WeightEntry> {
// ① React Native 側の SharedStorage に書き込み
let entry = WeightEntry (
id : UUID ().uuidString,
date : Date (),
weightKg : weightKg,
note : note
)
SharedStorage.shared. save ( entry : entry)
// ② Siri に読み上げさせる応答
let dialog = IntentDialog ( " \( weightKg ) kg を記録しました。" )
// ③ Entity を返すと Shortcuts の後続アクションから参照できる
return . result ( value : entry, dialog : dialog)
}
}
ここで注目したいのは戻り値の型 some IntentResult & ProvidesDialog & ReturnsValue<WeightEntry> です。3つの Protocol を組み合わせることで、「結果を返す」「Siri に読み上げさせる」「Shortcuts の後続ステップに値を渡す」の3つを同時に実現しています。この合成の柔軟さが AppIntent の設計で最も評価されている点です。
MyAppShortcuts.swift — AppShortcut の定義
AppShortcut は「アプリ起動直後から Shortcuts アプリに登録される定型ショートカット」です。ユーザーが設定しなくても自動で現れるため、最初の導線として非常に強力です。
// plugins/with-app-intents/AppIntents/MyAppShortcuts.swift
import AppIntents
struct MyAppShortcuts : AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut (
intent : LogWeightIntent (),
phrases : [
" \( . applicationName ) で体重を記録" ,
" \( . applicationName ) に \( \.$weightKg ) キロを記録" ,
],
shortTitle : "体重を記録" ,
systemImageName : "figure.walk"
)
}
/// AppShortcuts は iOS のビルド時に自動登録されるため、
/// アプリを起動する前から Siri や Shortcuts アプリで使える。
static var shortcutTileColor: ShortcutTileColor = .teal
}
phrases で \(.applicationName) を使う重要性 : Apple のガイドラインでは、App Shortcut のフレーズに必ずアプリ名を含めることが求められています。これを守らないと AppShortcut がそもそも登録されず、ビルド時に AppIntent の自動解析で警告が出ます。ローカライズする場合は、各言語の AppShortcuts.strings を用意する必要がある点も注意してください。
Step 3: React Native 側とのデータ共有
ここが Rork + App Intents でもっとも詰まりやすいポイントです。Swift 側で書いたデータ(体重記録)を React Native 側からも読み書きする必要があります。採用すべきは App Group を使った共有コンテナ です。
App Group は同一開発者の複数ターゲット(アプリ・ウィジェット・Intents Extension)で UserDefaults や FileSystem を共有できる仕組みです。Rork プロジェクトでは、Config Plugin で app.entitlements に App Group を追加します。
// plugins/with-app-intents/index.js (追加部分)
const { withEntitlementsPlist } = require ( "@expo/config-plugins" );
const withAppGroup = ( config , { appGroupIdentifier }) => {
return withEntitlementsPlist (config, ( cfg ) => {
cfg.modResults[ "com.apple.security.application-groups" ] = [
appGroupIdentifier,
];
return cfg;
});
};
// 呼び出し側
module . exports = ( config ) => {
config = withAppGroup (config, {
appGroupIdentifier: "group.com.example.myrorkapp" ,
});
config = withAppIntents (config);
return config;
};
React Native 側では、App Group 対応の AsyncStorage ラッパーを自作するか、expo-secure-store や MMKV の App Group 対応フォーク を使います。私は MMKV を App Group モードで使っており、Swift 側からも同じ Storage を参照するようにしています。
// Swift 側の SharedStorage 実装
import Foundation
final class SharedStorage {
static let shared = SharedStorage ()
private let defaults = UserDefaults ( suiteName : "group.com.example.myrorkapp" ) !
func save ( entry : WeightEntry) {
var all = (defaults. data ( forKey : "weight_entries" )
. flatMap { try? JSONDecoder (). decode ([WeightEntry]. self , from : $0 ) }) ?? []
all. append (entry)
if let encoded = try? JSONEncoder (). encode (all) {
defaults. set (encoded, forKey : "weight_entries" )
}
}
func recentEntries ( limit : Int ) -> [WeightEntry] {
guard let data = defaults. data ( forKey : "weight_entries" ),
let all = try? JSONDecoder (). decode ([WeightEntry]. self , from : data)
else { return [] }
return Array (all. sorted { $0 .date > $1 .date }. prefix (limit))
}
func fetchEntries ( ids : [ String ]) -> [WeightEntry] {
guard let data = defaults. data ( forKey : "weight_entries" ),
let all = try? JSONDecoder (). decode ([WeightEntry]. self , from : data)
else { return [] }
return all. filter { ids. contains ( $0 .id) }
}
}
extension WeightEntry : Codable {}
React Native 側から書き込むと、Shortcuts から実行した LogWeightIntent の SharedStorage.shared.save(entry:) が同じ Storage を参照するため、データが即座に同期します。
Step 4: Donations で Siri に学習させる
App Shortcuts は「固定」ですが、ユーザーが頻繁に使う操作を Siri が能動的に提案してくれる仕組みが Donations です。React Native 側でユーザーが体重を記録したタイミングで、Intent を donate することで「この時間帯にこの操作をする人だ」と Siri に学習させられます。
// Swift 側(NativeModule などから呼ぶ関数)
import AppIntents
@MainActor
func donateLogWeight ( weightKg : Double ) async {
let intent = LogWeightIntent ()
intent.weightKg = weightKg
await intent. donate ()
}
Rork が生成した React Native コードから呼ぶには Expo Modules 経由で JS→Swift を繋ぎます。実装のポイントは次の通りです。
体重を記録する画面で save() が成功したタイミングで donate する
同じ時間帯に複数回 donate しても Siri が重複を学習しないよう、1日1回程度で十分
ユーザーが操作を取り消したときは deleteAllDonations() で学習を消す
Donations は最初の1〜2週間で Siri が予測を始めるため、アプリをリリースした直後から仕込んでおくと効果的です。筆者は donate を後から足したアプリと最初から入れていたアプリの両方を運用していますが、後から足した側は Siri の提案が出るまでに明らかに時間がかかりました。差の大きさを数値で断定できるほどの対照実験にはしていないため、ここは傾向として受け取ってください。
Step 5: iOS 18 の Control Center ウィジェット対応
iOS 18 で Control Center が大幅に拡張され、サードパーティアプリが独自のコントロールを追加できるようになりました。Control Center から App Intent を直接実行できるため、「記録する」「開始する」といった頻出アクションを1タップで提供できます。
// plugins/with-app-intents/AppIntents/LogWeightControl.swift
import AppIntents
import WidgetKit
import SwiftUI
struct LogWeightControl : ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration (
kind : "com.example.myrorkapp.logweight"
) {
ControlWidgetButton ( action : LogWeightIntent ()) {
Label ( "体重を記録" , systemImage : "figure.walk" )
}
}
. displayName ( "体重を記録" )
. description ( "現在の体重をすぐに記録します" )
}
}
ここで一点、手順書には書かれにくい落とし穴があります。ControlWidget は WidgetKit の構成要素なので、アプリ本体のターゲットに置いてもコントロールとしては認識されません 。Widget Extension ターゲットに含める必要があります。これまでの AppShortcut や AppIntent はアプリ本体のターゲットに置いて動いていたので、同じ感覚で Config Plugin の addSourceFile に追加してしまいがちです。ビルドはそのまま通り、警告も出ず、Control Center の編集画面に何も出てこないという静かな失敗になります。
したがって Step 5 だけは、これまでと差し込み先が変わります。
ファイル 置き場所 理由
LogWeightIntent.swiftアプリ本体 + Widget Extension の両方 コントロールから呼ぶため、Extension 側からも参照できる必要があります
MyAppShortcuts.swift(AppShortcutsProvider)アプリ本体のみ Shortcuts / Siri への登録はアプリ本体が担います
LogWeightControl.swift(ControlWidget)Widget Extension のみ WidgetKit のコントロールは Extension でしか動きません
SharedStorage.swift両方(App Group 経由で同じ実体を見る) Extension は別プロセスなので、App Group がないとデータが見えません
Rork プロジェクトには Widget Extension が最初から用意されていないため、Config Plugin で PBXNativeTarget を追加するか、@bacons/xcode のようなユーティリティを使って生成することになります。ここは Config Plugin の中でもっとも記述量が増える部分です。個人開発で手を動かせる時間が夜と週末しかない前提なら、Control Center 対応は Step 4 までを本番投入したあとの第2弾に回すのが現実的だと私は考えています。
kind に一意の逆ドメイン文字列を割り当てる点も忘れないでください。これは WidgetKit の識別子として使われ、後から変更すると既存のユーザーがすでに追加したコントロールが消えます。
perform() の中でのエラー設計
Swift のエラーモデルは App Intents と相性が良く、押さえておきたい2つのパターンがあります。
ユーザー入力が原因で処理を続行できない場合(認証情報が誤っている、値が範囲外)は、LocalizedStringResource を持つ独自エラーを throw します。
enum LogWeightError : Error , LocalizedError {
case outOfRange ( Double )
var errorDescription: String ? {
switch self {
case . outOfRange ( let v) :
return "体重 \( v ) kg は許容範囲外です。"
}
}
}
func perform () async throws -> some IntentResult & ProvidesDialog {
guard ( 20 ... 300 ). contains (weightKg) else {
throw LogWeightError. outOfRange (weightKg)
}
// ...
}
Siri は errorDescription を読み上げ、Shortcuts アプリはステッププレビューに表示します。これだけで「黙って失敗する」を防げます。
パラメータが足りない場合(ユーザーが「体重を記録」とだけ言った場合)は、requestValueDialog を設定して Siri に聞き直させられます。
@Parameter (title : "体重 (kg)" , requestValueDialog : IntentDialog ( "体重を教えてください。" ))
var weightKg: Double
この1行で、ワンショットの Intent が会話的な音声 UI に変わります。音声処理のコードを1行も書かずに済むのが App Intents の面白さです。
よくある間違い・落とし穴
ここまでの実装でつまずく典型的な3つのポイントを、実際に私が遭遇した失敗と合わせて共有します。
落とし穴1: AppShortcuts が Shortcuts アプリに出てこない
ビルドは成功するのに、Shortcuts アプリで自分のアプリを選んでも AppShortcut が空欄になる症状です。原因は2パターンあります。
AppShortcutsProvider に準拠した struct が Config Plugin で Xcode プロジェクトのソースビルドフェーズに追加されていない — project.pbxproj を開いて MyAppShortcuts.swift が PBXBuildFile セクションに含まれているか確認します。含まれていない場合は、Config Plugin の addSourceFile 呼び出しがビルドごとに冪等になっているかを疑ってください
phrases に \(.applicationName) が入っていない — iOS 17 以降は必須です。警告として出ますが、AppShortcut 自体が登録されなくなります
落とし穴2: Shortcut 実行で React Native 側のデータが反映されない
Swift 側の SharedStorage.shared.save(entry:) は動いているのに、React Native 側で読み取ると古いデータしか出てこないケースです。原因は App Group の suite 名が React Native 側の MMKV / AsyncStorage と一致していない ことがほぼ100%です。
Swift 側: UserDefaults(suiteName: "group.com.example.myrorkapp")
JS 側: MMKV の id: "group.com.example.myrorkapp" かつ path を FileSystem.containerForSecurityApplicationGroupIdentifier(...) で取得した値に設定
Expo のドキュメントには App Group の扱いが散発的にしか書かれていないため、最初は両方の suite 名を同じ文字列定数(例: APP_GROUP_ID)に集約してしまうのが安全です。
とはいえ、定数に集約する前に一度は現状を洗い出す必要があります。私は次の小さなスクリプトをプロジェクト直下に置いて、entitlements・Swift・JS を横断して group. で始まる識別子を数え上げています。依存パッケージはなく、Node.js だけで動きます。
#!/usr/bin/env node
// audit-app-group.mjs — App Group 識別子が entitlements / Swift / JS で一致しているかを検査する
// 使い方: node audit-app-group.mjs <projectRoot>
import { readFileSync, readdirSync, statSync } from "node:fs" ;
import { join, extname } from "node:path" ;
const ROOT = process.argv[ 2 ] ?? "." ;
const SKIP = new Set ([ "node_modules" , ".git" , "build" , "Pods" , ".expo" ]);
const TARGET_EXT = new Set ([ ".swift" , ".js" , ".mjs" , ".ts" , ".tsx" , ".entitlements" , ".plist" ]);
const GROUP_RE = /group \. [A-Za-z0-9._-] + / g ;
function walk ( dir , out = []) {
for ( const name of readdirSync (dir)) {
if ( SKIP . has (name)) continue ;
const p = join (dir, name);
if ( statSync (p). isDirectory ()) walk (p, out);
else if ( TARGET_EXT . has ( extname (p))) out. push (p);
}
return out;
}
const hits = new Map ();
for ( const file of walk ( ROOT )) {
const text = readFileSync (file, "utf8" );
for ( const id of text. match ( GROUP_RE ) ?? []) {
if ( ! hits. has (id)) hits. set (id, new Set ());
hits. get (id). add (file);
}
}
if (hits.size === 0 ) {
console. log ( "App Group 識別子が1件も見つかりません。entitlements への追加漏れの可能性があります。" );
process. exit ( 1 );
}
const sorted = [ ... hits. entries ()]. sort (( a , b ) => b[ 1 ].size - a[ 1 ].size);
console. log ( `検出した App Group 識別子: ${ sorted . length } 種類 \n ` );
for ( const [ id , files ] of sorted) {
console. log ( `${ id } (${ files . size } ファイル)` );
for ( const f of [ ... files]. sort ()) console. log ( ` ${ f }` );
}
if (sorted. length > 1 ) {
console. log ( " \n 表記ゆれを検出しました。Swift と JS で別の識別子を参照している場合、" );
console. log ( "ビルドは通るのに Shortcut 実行時のデータだけが同期しません。" );
process. exit ( 2 );
}
console. log ( " \n 識別子は1種類に統一されています。" );
このスクリプトを、entitlements と Swift は myrorkapp、JS 側だけ myRorkApp と大文字が混じった状態のプロジェクトに対して Node.js 22 で実行すると、次の出力が返ります。
検出した App Group 識別子: 2 種類
group.com.example.myrorkapp (2 ファイル)
demo/ios/MyRorkApp.entitlements
demo/plugins/with-app-intents/AppIntents/SharedStorage.swift
group.com.example.myRorkApp (1 ファイル)
demo/src/storage.ts
表記ゆれを検出しました。Swift と JS で別の識別子を参照している場合、
ビルドは通るのに Shortcut 実行時のデータだけが同期しません。
JS 側を myrorkapp に揃えて再実行すると、こうなります。
検出した App Group 識別子: 1 種類
group.com.example.myrorkapp (3 ファイル)
demo/ios/MyRorkApp.entitlements
demo/plugins/with-app-intents/AppIntents/SharedStorage.swift
demo/src/storage.ts
識別子は1種類に統一されています。
App Group 識別子の大文字小文字は区別されます。人間の目には同じ文字列に見えるので、レビューで見つけるのは難しい部類の間違いです。終了コードを 0 / 1 / 2 で返しているので、eas build の前段や CI に挟んでおくと、そもそもこの症状に遭遇しなくなります。
落とし穴3: Donations が効かずに Siri の提案が一向に出ない
Donations を書いたのに、2週間経っても Siri が提案してくれないという相談をよく受けます。考えられる原因は3つあります。
Simulator でテストしている — Donations は実機でしか学習しません。TestFlight ビルドで最低1週間は自分でショートカットを使い続けてから本番提出してください
ユーザーが Siri 提案を OFF にしている — 「設定 → Siri と検索 → ショートカットと提案」で該当アプリがオフになっていないか確認する UI を作るか、記事で周知する必要があります
donate するタイミングが早すぎる — save() 前に donate すると、まだユーザーが本当にその操作をしたか確信できません。必ず成功コールバックの中で donate してください
効果測定: 「本当に効いたか」を見るための計装
App Intents を入れても、計装しなければ本当に効いたのかわかりません。最低限仕込みたいイベントは2つです。
intent_performed : 各 AppIntent.perform() の中で intent 名・呼び出し元(siri / shortcuts / widget / control_center)・起動ごとに発行した session_id を送信する
intent_donated : donate() を呼んだタイミングで送る。「donate した回数」と「実際に perform された回数」の相関を追うと、Siri が学習しているか・ユーザーが手動で Shortcut に追加したかが分離できる
呼び出し元は Apple が直接教えてくれないため、perform() の先頭で UIApplication.shared.applicationState を参照し、フォアグラウンドなら widget / control_center、バックグラウンドなら siri / shortcuts と推定してイベントに乗せます。
PostHog、Mixpanel、あるいは Cloudflare Analytics Engine にこの2つを流し込めば、次の3つのダッシュボードが組めます。
採用ファネル : 新規ユーザー → AppIntent を1回以上実行 → 初週に3回以上実行
呼び出し元の構成比の時系列 : widget / control_center が減って siri が増えるなら Donations が効いている証拠
リテンション比較 : 「初週に AppIntent を使った群 vs 使わなかった群」のリテンションカーブ。これが追加投資を正当化する材料になります
計装を省くと App Intents はブラックボックスになりますが、入れてしまえば「どのショートカットが本当に再訪を生んでいるか」がわかり、リソース配分を自信を持って意思決定できます。
プライバシーと App Store 審査で気をつけたいこと
App Intents はユーザーデータと Siri の両方に触れるため、審査で指摘を受けやすいポイントがあります。私が実際の審査で遭遇した学びを2つ共有します。
1つ目。AppIntent の中で外部 API を叩く場合は PrivacyInfo.xcprivacy にドメインと目的を申告する必要があります。2024 年初頭から審査が厳しくなり、リジェクト理由としてよく見かけるようになりました。本記事の Config Plugin 雛形に PrivacyInfo.xcprivacy のプレースホルダを含めてあるので、触れるドメインと NSPrivacyTrackingReason を埋めてください。書き方は Rorkで作ったアプリの審査が、プライバシーマニフェスト不備で止まらないようにする にまとめてあります。
2つ目。Siri の phrases にユーザーの個人データを連想させる表現を入れすぎないこと。"MyApp に体重を記録" は問題ありませんが、"MyApp に山田さんへメールを送らせて" のような連絡先・送信系の動作を想起させる phrase は、審査担当者から権限の根拠を問われがちです。AppShortcut の phrase は自分のアプリのデータ操作に厳密に絞るのが無難です。
あえて入れない方がいい場面
ここまで「入れましょう」と書いてきましたが、正直に言うと全員に勧められるわけではありません。次のタイプのアプリでは App Intents を後回しにした方がいいです。
単発ユーティリティ系 (計算機・バーコード生成器・タイムゾーン変換)— 1回30秒で完結し習慣化しないアプリでは、Donations の学習素材がそもそも貯まりません
サーバー状態に依存するアプリ (ライブスコアボード・チャット)— AppIntent は動きますが、「アプリを開かずに処理を完結させる」部分がサーバー遅延で台無しになります。このタイプには Widget で新しい TimelineEntry を出す方が素直です
初期 MVP — 「ユーザーがどの操作を習慣化するか」を読みきれていない段階での AppShortcut は当てずっぽうです。100 人規模の本番ユーザーが貯まってから設計する方が命中率が上がります
Rork アプリがどれかに当てはまるなら、その工数はオンボーディングの磨き込みに回した方が費用対効果が高いです。私自身、最初のアプリでは習慣化の見立てを外して AppShortcut を3つ作り、そのうち2つは誰にも使われないまま次のバージョンで削りました。App Intents はユーザーが何を習慣化するか見えてから戻ってくればいい、というのはそのときの反省でもあります。
本番運用を見据えた設計ポイント
ここまでを踏まえて、本番にリリースするときに追加で押さえておきたい設計判断を3つ挙げます。
1. Shortcut を「取り消せる」設計にする
Siri や Shortcuts から実行された Intent は、ユーザーがアプリを開かずに完結します。これは便利な反面、誤操作のリカバリが難しいという副作用があります。私は LogWeightIntent の結果ダイアログに「取り消す」アクションを仕込み、タップすると直前の Entry を削除する UndoLogWeightIntent を呼び出す設計にしています。
2. Focus Filter に対応する
iOS 16 以降の Focus Filter に対応すると、「仕事モード中は体重記録ショートカットを非表示にする」といったユーザー体験が提供できます。技術的には FocusFilterIntent に準拠した構造体を追加するだけです。AI や自己管理系のアプリでは、ユーザーからの評価が明確に上がる機能です。
3. AppIntent のテストを CI に組み込む
AppIntent は perform() が純粋な async 関数なので、XCTest でユニットテストが書けます。私は LogWeightIntent.perform() を実行して SharedStorage の状態を検証するテストを Xcode Cloud 上で回しており、リリース前の回帰バグを何度も防げました。Rork プロジェクトでも ios/ 直下に MyRorkAppTests ターゲットを作り、Config Plugin でテストターゲットにも Swift ファイルが含まれるようにすると同じ構成にできます。
関連する上級トピックとしては、Rork Expo Modules でカスタム Native モジュールを書く完全ガイド と AI アプリで Function Calling を実装するための設計ガイド が参考になります。App Intents と Function Calling は「AI から自分のアプリの機能を呼ぶ」という意味で設計思想が似ており、両方を理解すると AI エージェント時代のアプリ設計が見通せるようになります。前者を Config Plugin より先に読んでおくと、@Parameter や some IntentResult & ProvidesDialog のような型表現の意味が腹落ちしやすくなります。
静かに壊れたときのデバッグチェックリスト
App Intents は静かに失敗することが多いので、「動かない」と思ったら次の順で切り分けます。10 回中 9 回はステップ 6 に到達する前に解決します。
Swift ファイルがコンパイルされているか確認します。Xcode の Product → Show Build Folder で .app バンドルの中を開き、Metadata.appintents が存在するか見ます。なければ Xcode が AppIntents のインデックスを作っていません
AppShortcutsProvider がリンクされているか確認します。static 初期化子に print("AppShortcutsProvider loaded") を仕込み、アプリ起動後の実機コンソールで出力されるか見ます
App Group の suite 名を両側で比較します。Swift 側の UserDefaults(suiteName: ...)?.dictionaryRepresentation().keys と JS 側の MMKV のキー一覧を並べ、同じキーが入っていることを確認します
phrases がコンパイル警告なしで通るか確認します。\(.applicationName) が抜けていると警告だけ出て AppShortcut が落ちます
Siri に学習のリセットをかける。設定 → Siri と検索 → About Siri で「Siri 提案をリセット」してから donate し直し、24 時間待ちます
アプリをまっさらに入れ直す。AppShortcuts のメタデータは強くキャッシュされるため、クリーンインストールで初めて出てくるケースがあります
ステップ 6 まで到達しても動かない場合、ターゲットのビルド設定で EnableAppIntents = YES が消えていないか確認してください。iOS 16+ SDK ではデフォルト ON ですが、古い Podfile の post-install スクリプトが残っている Rork プロジェクトで稀に上書きされます。
全体を振り返って: 今日からできる最初の一歩
App Intents の導入は、Rork アプリで「1度しか使われないアプリ」を「毎日使われるアプリ」に変える最もコスパの良い改善策のひとつです。これから手をつけるなら、次の順番がおすすめです。
まず LogWeightIntent のような「1操作・0パラメータ or 1パラメータ」の最小 Intent を1つだけ実装し、TestFlight で自分のデバイスに配布してください。自分で Shortcuts アプリから実行できる状態になったら、それだけで十分なスタート地点です。そこから Entity Query、Donations、Control Center ウィジェットへと段階的に広げていくと、破綻なく本番品質に到達できます。1つ目の Intent を今日中に動かすこと。そこから先は、実際に自分で使ってみて要らないと感じたものを削っていく作業になります。