Rork で App Store に出したアプリのダウンロードが伸び悩んだとき、最初に試したい改善策のひとつが Siri と Shortcuts への対応です。筆者は2本のアプリで App Intents を導入してから、オーガニックな再起動率(DAU / MAU)が明らかに改善しました。理由はシンプルで、iOS 側がユーザーの行動パターンを学習して「朝7時にこのアプリを開く人にショートカットを提案する」ところまでやってくれるからです。
一方で、Rork が生成してくれるのは基本的に Expo 寄りの React Native コードです。App Intents は iOS 16 で導入された純 Swift API なので、そのままでは手が届きません。ここではRork プロジェクトに対して Expo Config Plugin を通じて App Intents を組み込み、AppShortcut・AppEntity・IntentPerformer を正しく設計するまでの流れを、筆者が実際に本番投入した構成ベースで解説します。8,000 文字超の長めの記事ですが、終盤に進むほど「なぜそう書くのか」の設計判断に踏み込んでいくので、目次で気になる箇所から読み進めても構いません。
「Rork Max」表記について補足
記事中で「Rork Max」と書かれた箇所を見た方もいるかもしれませんが、この記事の手法は現行 Rork と Rork Max のどちらでも同じように使えます。違いは「カスタマイズ可能な Xcode プロジェクトを書き出せる高度なビルドパイプラインが使えるか」だけです。無料プランでローカルに ios/ ディレクトリが見えない場合は、npx expo prebuild を実行するか、上位プランへ切り替えてから Config Plugin を追加してください。そこから先は同じ手順です。
App Intents が本当に効くのは「2回目以降の起動」
Apple の公式ドキュメントは App Intents を「Siri・Spotlight・Shortcuts アプリから自分のアプリの機能を呼び出せる仕組み」と説明していますが、これだけでは導入の動機が弱いです。私の感覚では、App Intents の本当の価値は 再起動時のフリクション低減 にあります。
ユーザーが一度アプリを使った後、次にそのアプリを開くまでの間に iOS は Siri・Spotlight・ウィジェット・Control Center・ロック画面など多くの「入り口」を提供しています。App Intents に対応すると、こうした入り口すべてから直接アプリの特定機能を呼び出せるようになります。例えば「朝起きたら今日の天気を読み上げる」「昼休みに昨日記録した体重を入力する」といった習慣的な操作が、アプリを開かずに1タップ(または音声)で完結します。
現場で効く指標は次の3つです。
- D7 リテンション: Siri 提案が出るようになってから、私の観測範囲では +8〜12 ポイント改善しました
- セッション時間の短縮: これはネガティブに見えますが、1回あたりの作業が Shortcuts で完結するため健全な変化です
- ロック画面ウィジェットからの起動率: iOS 17 以降、ウィジェットに直接 App Intent を貼れるため、ホーム画面よりもロック画面経由の流入が増えました
Rork が生成するアプリは「AI で便利な機能を作りやすい」反面、「ユーザーに繰り返し使ってもらう導線」を考えないと、ストア評価の伸び悩みに直結します。App Intents はこの導線問題に最も素直に効く投資です。
なぜ 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 が予測を始めるため、アプリをリリースした直後から仕込んでおくと効果的です。筆者はここを仕込んでいなかったアプリと、最初から仕込んでいたアプリで、3ヶ月後のオーガニック再起動率に約15%の差が出ました。
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("現在の体重をすぐに記録します")
}
}
このファイルを Config Plugin で追加すると、iOS 18 以降のユーザーは Control Center の編集画面から自分のアプリのコントロールを追加できるようになります。ここでのポイントは 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)に集約してしまうのが安全です。
落とし穴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 を埋めてください。書き方は アプリプライバシーマニフェスト API 宣言の修正ガイド にまとめてあります。
2つ目。Siri の phrases にユーザーの個人データを連想させる表現を入れすぎないこと。"MyApp に体重を記録" は問題ありませんが、"MyApp に山田さんへメールを送らせて" のような連絡先・送信系の動作を想起させる phrase は、審査担当者から権限の根拠を問われがちです。AppShortcut の phrase は自分のアプリのデータ操作に厳密に絞るのが無難です。
あえて入れない方がいい場面
ここまで「入れましょう」と書いてきましたが、正直に言うと全員に勧められるわけではありません。次のタイプのアプリでは App Intents を後回しにした方がいいです。
- 単発ユーティリティ系(計算機・バーコード生成器・タイムゾーン変換)— 1回30秒で完結し習慣化しないアプリでは、Donations の学習素材がそもそも貯まりません
- サーバー状態に依存するアプリ(ライブスコアボード・チャット)— AppIntent は動きますが、「アプリを開かずに処理を完結させる」部分がサーバー遅延で台無しになります。このタイプには Widget で新しい
TimelineEntry を出す方が素直です
- 初期 MVP— 「ユーザーがどの操作を習慣化するか」を読みきれていない段階での AppShortcut は当てずっぽうです。100 人規模の本番ユーザーが貯まってから設計する方が命中率が上がります
Rork アプリがどれかに当てはまるなら、その工数はオンボーディングの磨き込みに回した方が費用対効果が高いです。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 を今日中に動かすこと、それが読者にとっての最速のアクションです。