本文基于一个 iOS 26 示例工程整理,主要覆盖 App Intents、App Shortcuts、App Entity、Entity Query、Spotlight 索引和 UIKit 导航桥接。
一、App Intents 到底解决什么问题
传统 App 的入口通常是:用户打开 App,找到首页,再点击某个 Tab 或菜单。App Intents 可以把 App 的“动作”和“业务对象”声明给系统,让用户从以下入口直接调用:
- Siri 或系统语音入口;
- 快捷指令 App;
- Spotlight 搜索结果;
- 系统根据 App Shortcuts 生成的建议入口;
- 其他支持 App Intents 的系统自动化流程。
例如,用户可以说“打开 目标 App 的旅游”,系统解析出 travel 这个业务入口,App 再通过自己的 Router 进入旅游页面。
这里要区分两件事:
- 系统是否能理解用户想做什么:由 Intent、参数、同义词和 Query 负责。
- App 最终打开哪个页面:由现有 UIKit 控制器和 Router 负责。
App Intents 不会自动替你完成业务导航。它只是系统和 App 业务之间的声明式桥梁。
二、整体架构
本次实现把代码拆成几个职责明确的部分:
系统入口
|
+-- App Shortcuts:声明用户可说的短语
|
+-- App Intent:描述动作、参数和执行结果
|
+-- App Enum / App Entity:描述固定入口或动态业务对象
|
+-- Entity Query:让系统能够查找和筛选对象
|
+-- Spotlight Index:写入可搜索内容和实体
|
+-- AppDelegate / SceneDelegate:接收系统活动
|
+-- ExampleIntentNavManager:把系统动作转换成现有 AppRouter 导航
对应文件位于:
ExampleApp/AppIntents/
├── ExampleIntentActions.swift
├── ExampleIntentDataStore.swift
├── ExampleIntentModels.swift
├── ExampleIntentNavigation.swift
├── ExampleIntentQueries.swift
├── ExampleIntentSetupCenter.swift
├── ExampleIntentShortcuts.swift
└── ExampleIntentViews.swift
三、App Intent、App Enum 和 App Entity 的区别
1. App Intent:一个可以执行的动作
AppIntent 表示“用户希望 App 做一件事”。例如打开某个模块、推荐服务、创建订单或查询职位。
最小结构如下:
@available(iOS 26.0, *)
struct OpenTravelIntent: AppIntent {
static let title: LocalizedStringResource = "打开旅游"
@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog {
// 在这里调用应用自己的导航层。
return .result(dialog: "正在打开旅游")
}
}
perform() 是系统真正执行动作的地方。它可以返回对话文本、实体值或 SwiftUI 结果片段。
2. App Enum:数量固定的选项
目标 App 的旅游、酒店民宿、求职、招聘等入口是固定的,因此使用 AppEnum:
@available(iOS 26.0, *)
enum ExampleIntentRouteType: String, AppEnum {
case aiAssistant
case travel
case hotel
case jobSearch
case recruitment
case aiShop
static let typeDisplayRepresentation: TypeDisplayRepresentation = "目标 App 功能"
static let caseDisplayRepresentations: [ExampleIntentRouteType: DisplayRepresentation] = [
.travel: DisplayRepresentation(
title: "旅游",
image: .init(systemName: "airplane"),
synonyms: ["旅行", "出游"]
),
.hotel: DisplayRepresentation(
title: "酒店民宿",
image: .init(systemName: "bed.double"),
synonyms: ["酒店", "民宿", "住宿"]
)
]
}
DisplayRepresentation 中的 synonyms 很重要。用户可能说“酒店”,但产品入口名称是“酒店民宿”;没有同义词时,系统匹配成功率会下降。
3. App Entity:系统可以识别的业务对象
当对象不是固定枚举,而是服务、订单、酒店、职位或商品时,应使用 AppEntity。本项目使用 IndexedEntity,让实体还可以被系统索引:
@available(iOS 26.0, *)
struct ExampleServiceEntity: IndexedEntity {
var id: String { item.id }
@ComputedProperty(title: "名称", indexingKey: \.displayName)
var name: String { item.route.title }
@ComputedProperty(title: "说明", indexingKey: \.contentDescription)
var summary: String { item.route.summary }
let item: ExampleIntentServiceItem
static let typeDisplayRepresentation = TypeDisplayRepresentation(
name: "目标 App服务"
)
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(
title: "\(name)",
subtitle: "\(summary)",
image: .init(systemName: item.route.symbolName)
)
}
static let defaultQuery = ExampleServiceQuery()
}
实体必须有稳定的 id。不要在每次启动时随机生成 ID,否则 Spotlight、快捷指令和历史捐赠的实体无法恢复。
四、用数据仓库隔离系统层和业务层
Intent 不应该在 perform() 中散落业务数据。项目通过 ExampleIntentDataStore 集中保存服务目录、关键词和最近使用记录:
@available(iOS 26.0, *)
struct ExampleIntentServiceItem: Sendable {
let id: String
let route: ExampleIntentRouteType
let keywords: [String]
}
@available(iOS 26.0, *)
nonisolated final class ExampleIntentDataStore: @unchecked Sendable {
static let shared = ExampleIntentDataStore()
private let items: [ExampleIntentServiceItem] = [
ExampleIntentServiceItem(
id: "travel",
route: .travel,
keywords: [ "旅游", "旅行", "景点", "行程"]
),
ExampleIntentServiceItem(
id: "hotel",
route: .hotel,
keywords: [ "酒店", "民宿", "住宿", "房间"]
)
]
}
这样做有三个好处:
- Intent、Query 和 Spotlight 使用同一份业务目录;
- 稳定 ID、标题、摘要和关键词集中维护;
- 以后把静态目录替换成数据库或接口缓存时,不需要修改系统入口层。
五、实现三个典型 Intent
1. 参数化导航 Intent
固定业务入口使用 AppEnum 参数:
@available(iOS 26.0, *)
struct ExampleIntentNavAction: AppIntent {
static let title: LocalizedStringResource = "打开 目标 App 功能"
static let description = IntentDescription(
"打开 目标 App 中的指定核心功能。"
)
static let supportedModes: IntentModes = .foreground
static var parameterSummary: some ParameterSummary {
Summary("打开\(\.$route)")
}
@Parameter(title: "功能", requestValueDialog: "你想打开哪个功能?")
var route: ExampleIntentRouteType
@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog {
ExampleIntentNavManager.shared.open(route: route)
return .result(dialog: "正在打开\(route.title)")
}
}
supportedModes = .foreground 表示这个动作需要把 App 带到前台。适合打开 UIKit 页面,不适合纯后台计算。
2. 后台推荐 Intent
如果动作只需要返回结果,不需要打开 App,可以返回实体、语音文本和结果片段:
@available(iOS 26.0, *)
struct ExampleIntentRecommendAction: AppIntent {
static let title: LocalizedStringResource = "推荐 目标 App服务"
static let description = IntentDescription(
"根据最近使用情况推荐一个 目标 App服务。"
)
@Dependency var dataStore: ExampleIntentDataStore
@MainActor
func perform() async throws
-> some ReturnsValue<ExampleServiceEntity> & ProvidesDialog & ShowsSnippetView {
guard let item = dataStore.recommendedItem() else {
throw ExampleIntentDataError.serviceMissing
}
let entity = ExampleServiceEntity(item: item)
return .result(
value: entity,
dialog: "推荐你使用\(entity.name)",
view: ExampleIntentResultView(entity: entity)
)
}
}
这里的三个返回能力分别表示:
-
ReturnsValue:将实体传给快捷指令的下一步; -
ProvidesDialog:Siri 或系统界面可以朗读/显示文本; -
ShowsSnippetView:在支持的有屏幕场景显示 SwiftUI 结果片段。
3. 打开实体 Intent
对实体使用 OpenIntent:
@available(iOS 26.0, *)
struct ExampleIntentOpenAction: OpenIntent, TargetContentProvidingIntent {
static let title: LocalizedStringResource = "打开 目标 App服务"
static var parameterSummary: some ParameterSummary {
Summary("打开\(\.$target)")
}
@Parameter(title: "服务", requestValueDialog: "你想打开哪个服务?")
var target: ExampleServiceEntity
var contentIdentifier: String { target.id }
}
contentIdentifier 让系统把一次打开动作和具体实体 ID 关联起来。这里仍然只是声明目标,真正的 UIKit 导航要通过桥接转交给路由层。
六、Entity Query:系统如何找到实体
本项目的 Query 同时实现了三类能力:
@available(iOS 26.0, *)
struct ExampleServiceQuery:
EntityStringQuery,
EnumerableEntityQuery,
EntityPropertyQuery {
@Dependency var dataStore: ExampleIntentDataStore
func entities(for identifiers: [ExampleServiceEntity.ID]) async throws
-> [ExampleServiceEntity] {
dataStore.items(ids: identifiers).map(ExampleServiceEntity.init)
}
func suggestedEntities() async throws -> [ExampleServiceEntity] {
dataStore.suggestedItems().map(ExampleServiceEntity.init)
}
func allEntities() async throws -> [ExampleServiceEntity] {
dataStore.allItems().map(ExampleServiceEntity.init)
}
func entities(matching string: String) async throws -> [ExampleServiceEntity] {
dataStore.matching(string).map(ExampleServiceEntity.init)
}
}
Query Builder 的正确写法
这是本次实现中最容易出错的地方。properties 的 Builder 内必须直接返回 Property;sortingOptions 的 Builder 内必须直接返回 SortableBy:
static var properties = QueryProperties {
Property(\ExampleServiceEntity.$name) {
ContainsComparator { ExampleIntentFilter.name($0) }
}
Property(\ExampleServiceEntity.$route) {
EqualToComparator { ExampleIntentFilter.route($0) }
}
}
static var sortingOptions = SortingOptions {
SortableBy(\ExampleServiceEntity.$name)
}
不要写成下面这样:
// 错误:Builder 内再次返回 QueryProperties。
static var properties = QueryProperties {
QueryProperties {
// ...
}
}
也不要把 SortingOptions 当作 SortableBy 放到另一个 Builder 里面。否则会出现:
Invalid builder syntax for query properties definition,
expected 'Property' but got 'QueryProperties' instead
Invalid builder syntax for query 'sortingOptions',
expected a 'SortableBy' call but got 'SortingOptions' instead
实际执行过滤和排序
Builder 只负责把字段声明给系统,真正的业务过滤仍然由 Query 方法处理:
func entities(
matching filters: [ExampleIntentFilter],
mode: ComparatorMode,
sortedBy sorts: [Sort<ExampleServiceEntity>],
limit: Int?
) async throws -> [ExampleServiceEntity] {
var matchedItems: [ExampleIntentServiceItem] = []
for item in dataStore.allItems() {
let results = filters.map {
Self.matches(item: item, filter: $0)
}
let isMatched: Bool
if filters.isEmpty {
isMatched = true
} else if mode == .and {
isMatched = results.allSatisfy { $0 }
} else {
isMatched = results.contains(true)
}
if isMatched {
matchedItems.append(item)
}
}
let orderedItems = Self.sort(items: matchedItems, sorts: sorts)
let safeLimit = max(0, limit ?? orderedItems.count)
return orderedItems.prefix(safeLimit).map(ExampleServiceEntity.init)
}
这里要特别处理空条件、负数 limit、失效 ID 和排序字段,避免系统传入异常参数时崩溃。
七、App Shortcuts:让用户真的能说出来
只有实现 AppIntent,用户未必能在系统中方便地发现它。AppShortcutsProvider 负责把动作注册成可发现的快捷入口:
@available(iOS 26.0, *)
struct ExampleIntentShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
[
AppShortcut(
intent: ExampleIntentNavAction(),
phrases: [
"\(.applicationName)的\(\.$route)",
"打开\(.applicationName)的\(\.$route)",
"在\(.applicationName)里打开\(\.$route)"
],
shortTitle: "打开功能",
systemImageName: "arrow.forward.app"
)
]
}
static let shortcutTileColor: ShortcutTileColor = .blue
}
\(.applicationName) 会由系统替换成 App 名称。在这个示例中,最终目标是让用户说出类似:
打开 目标 App 的旅游
打开 目标 App 的酒店
在 目标 App 里打开求职
短语应当包含 App 名称,参数化短语也必须和 @Parameter 的类型匹配。短语不是任意字符串模板,写错参数表达式会导致系统无法生成快捷操作。
八、Spotlight:显式索引和实体索引是两条通道
1. CSSearchableItem:传统、可控的搜索结果
项目为每个服务创建 CSSearchableItem:
let attributeSet = CSSearchableItemAttributeSet(contentType: .item)
attributeSet.title = "目标 App 旅游"
attributeSet.displayName = "旅游"
attributeSet.contentDescription = "查找景点并规划旅行行程"
attributeSet.keywords = ["目标 App", "旅游", "旅行", "景点"]
attributeSet.supportsNavigation = true
let searchItem = CSSearchableItem(
uniqueIdentifier: "qsc.service.travel",
domainIdentifier: "com.tianshuyuhui.cloud.services",
attributeSet: attributeSet
)
searchItem.expirationDate = .distantFuture
try await CSSearchableIndex.default().indexSearchableItems([searchItem])
这种方式的优势是标题、摘要、关键词和点击后的标识都能明确控制,适合固定的 App 内入口。
2. indexAppEntities:让系统理解 App Entity
对于 IndexedEntity,项目还会调用:
let entities = serviceItems.map(ExampleServiceEntity.init)
try await CSSearchableIndex.default().indexAppEntities(entities)
两者不是互相替代的关系:
-
CSSearchableItem适合显式控制 Spotlight 搜索结果; -
indexAppEntities让系统获得实体的语义和属性信息; - 两条通道可以同时使用,但必须保证 ID、名称和关键词一致。
3. 点击 Spotlight 后如何恢复页面
点击结果时系统会发送 NSUserActivity。项目通过稳定前缀解析业务 ID:
static func handle(userActivity: NSUserActivity) -> Bool {
guard userActivity.activityType == CSSearchableItemActionType else {
return false
}
guard let identifier = userActivity.userInfo?
[CSSearchableItemActivityIdentifier] as? String else {
return false
}
guard identifier.hasPrefix("qsc.service.") else {
return false
}
let serviceId = String(identifier.dropFirst("qsc.service.".count))
guard let item = ExampleIntentDataStore.shared.allItems()
.first(where: { $0.id == serviceId }) else {
return false
}
ExampleIntentNavManager.shared.open(
route: item.route,
entityId: item.id
)
return true
}
九、启动时注册 Intent 和 Spotlight
在 App 启动早期统一注册依赖、快捷操作和索引:
func configure() {
let dataStore = ExampleIntentDataStore.shared
AppDependencyManager.shared.add(dependency: dataStore)
ExampleIntentShortcuts.updateAppShortcutParameters()
guard CSSearchableIndex.isIndexingAvailable() else {
return
}
let serviceItems = dataStore.allItems()
let entities = serviceItems.map(ExampleServiceEntity.init)
let searchItems = serviceItems.map(makeSearchItem)
Task {
do {
try await CSSearchableIndex.default()
.indexSearchableItems(searchItems)
} catch {
DebugPrint("Spotlight 内容索引失败:\(error.localizedDescription)")
}
do {
try await CSSearchableIndex.default()
.indexAppEntities(entities)
} catch {
DebugPrint("App Entity 索引失败:\(error.localizedDescription)")
}
}
}
AppDelegate 中尽早调用:
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
if #available(iOS 26.0, *) {
ExampleIntentSetupCenter.configure()
}
return true
}
索引失败不能阻塞 App 冷启动。Spotlight 是增强入口,不应该成为主业务启动的硬依赖。
十、UIKit 项目为什么需要 SwiftUI 桥接
ShowsSnippetView 和 onAppIntentExecution 使用 SwiftUI API,而项目主体是 UIKit。项目通过透明的 UIHostingController 把 SwiftUI 节点挂到现有控制器树中:
@available(iOS 26.0, *)
struct ExampleIntentBridgeView: View {
let onOpen: (ExampleServiceEntity) -> Void
var body: some View {
let openHandler = onOpen
return Color.clear
.onAppIntentExecution(ExampleIntentOpenAction.self) { action in
openHandler(action.target)
}
}
}
UIKit 控制器安装桥接节点:
private func setupAppIntentBridge() {
guard #available(iOS 26.0, *) else {
return
}
let bridgeView = ExampleIntentBridgeView { entity in
ExampleIntentNavManager.shared.open(entity: entity)
}
let hostController = UIHostingController(rootView: bridgeView)
addChild(hostController)
view.addSubview(hostController.view)
hostController.view.backgroundColor = .clear
hostController.view.isUserInteractionEnabled = false
hostController.view.snp.makeConstraints { make in
make.top.leading.equalToSuperview()
make.width.height.equalTo(1)
}
hostController.didMove(toParent: self)
}
这个 SwiftUI View 不是业务页面,也不负责网络请求和页面跳转。它只监听系统执行事件,再把实体交给控制器层。
如果桥接 View 没有挂载,常见表现是:App 能被唤起,但实体打开动作没有进入具体页面。这不是 Intent 参数一定错误,而可能是系统事件没有接收者。
十一、冷启动、热启动和隐私弹窗
系统触发 Intent 时,App 可能处于三种状态:
- App 已在前台,主控制器已经存在;
- App 在后台,进程还在,但页面需要恢复;
- App 冷启动,隐私弹窗或根控制器还没有安装。
项目使用 ExampleIntentNavManager 保存暂时不能执行的路由:
@MainActor
final class ExampleIntentNavManager {
private var pendingRoute: ExampleIntentRouteType?
func open(route: ExampleIntentRouteType, entityId: String? = nil) {
guard let root = keyWindow?.rootViewController,
containsAI(root) else {
pendingRoute = route
return
}
pendingRoute = nil
routeNow(route)
}
func consumePendingRoute() {
guard let pendingRoute else {
return
}
open(route: pendingRoute)
}
}
主页面安装完桥接 View 后,再调用 consumePendingRoute()。这样可以避免在隐私弹窗期间强行绕过用户授权,也避免向不存在的导航栈推控制器。
十二、AppDelegate 和 SceneDelegate 的事件入口
不能只处理 AppDelegate。使用 Scene 生命周期的 App,还要同时覆盖 SceneDelegate:
func application(
_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
if #available(iOS 26.0, *),
ExampleIntentSetupCenter.handle(userActivity: userActivity) {
return true
}
return AppOpenURLHandler.handleUserActivity(userActivity)
}
func scene(
_ scene: UIScene,
continue userActivity: NSUserActivity
) {
if #available(iOS 26.0, *),
ExampleIntentSetupCenter.handle(userActivity: userActivity) {
return
}
_ = AppOpenURLHandler.handleUserActivity(userActivity)
}
冷启动时还要读取 connectionOptions.userActivities,因为第一次连接 Scene 时,活动可能还没有走到普通的 scene(_:continue:)。
十三、统一导航层如何复用现有 Router
Intent 不直接创建一堆控制器,而是把枚举映射到现有 AppRouter:
@MainActor
private func routeNow(_ route: ExampleIntentRouteType) {
let navigationController = UIViewController.current()?
.navigationController as? BaseNavigationController
let router = AppRouter(navigationController: navigationController)
switch route {
case .aiAssistant:
router.resetToAISearchRoot()
case .travel:
router.resetToAISearchRoot(andPush: TravelViewController())
case .hotel:
router.resetToAISearchRoot(andPush: HotelViewController())
case .jobSearch:
router.resetToAISearchRoot(andPush: JobsViewController())
case .recruitment:
router.resetToAISearchRoot(
andPush: RecruitmentViewController(initialIndex: 0)
)
case .aiShop:
router.resetToAISearchRoot(andPush: ShopViewController())
}
}
这样做的关键是:系统入口只依赖抽象路由枚举,业务页面的创建和栈操作仍由 App 内部统一管理,降低 App Intents 与 UIKit 页面之间的耦合。
十四、Transferable 的作用
ExampleServiceEntity 还实现了 Transferable,把实体转换为 PNG 图片,供支持数据传递的快捷指令动作使用:
@available(iOS 26.0, *)
extension ExampleServiceEntity: Transferable {
static var transferRepresentation: some TransferRepresentation {
DataRepresentation(exportedContentType: .png) { entity in
guard let data = UIImage(
systemName: entity.item.route.symbolName
)?.pngData() else {
throw ExampleIntentDataError.imageDataMissing
}
return data
}
}
}
这不是页面跳转机制,而是“把 Intent 的结果作为数据交给下一个动作”的机制。实际项目中也可以导出 JSON、URL 或文件,但应使用明确的 UTType,避免把业务对象转换成没有语义的字符串。
十五、测试方法
1. 快捷指令 App
在真机上按以下顺序测试:
- 打开“快捷指令”App;
- 搜索“目标 App”或“打开功能”;
- 添加“打开 目标 App 功能”;
- 选择“旅游”或“酒店民宿”;
- 运行快捷指令;
- 确认 App 被唤起并进入对应页面。
如果只能唤起 App,不能进入页面,优先检查:
-
ExampleIntentNavManager是否在主线程执行; -
setupAppIntentBridge()是否已经挂载; - 主页面是否仍在隐私弹窗阶段;
-
pendingRoute是否在主页面完成后消费; - Router 是否使用了当前有效的导航控制器。
2. Spotlight
Spotlight 是系统搜索入口,不是 App 资源库。测试时在主屏幕向下滑,搜索以下完整关键词:
目标 App 旅游
目标 App 酒店
目标 App 求职
首次安装或首次启动后,系统索引可能有延迟。确认日志中出现索引成功信息后,再测试搜索。若只有 App 名称没有服务结果,检查 CSSearchableItem 的 title、keywords、uniqueIdentifier 和 expirationDate。
3. Siri 或语音入口
测试短语必须包含 App 名称,例如:
打开 目标 App 的旅游
在 目标 App 里打开酒店
让目标 App 推荐服务
不要把“只唤起 App”误认为已经完成业务动作。真正的验收标准是:
系统解析参数 -> App 前台运行 -> 主页面准备完成 -> Router 推入目标页面
4. 冷启动和热启动
至少覆盖以下组合:
| 场景 | 预期结果 |
|---|---|
| App 前台运行时执行 | 直接进入目标页面 |
| App 后台运行时执行 | 恢复 App 后进入目标页面 |
| App 被杀掉后执行 | 冷启动,完成初始化后进入目标页面 |
| 首次安装且隐私弹窗未处理 | 暂存路由,不绕过隐私同意 |
| 搜索结果过期或 ID 不存在 | 安全忽略,不崩溃 |
十六、常见误区
误区一:把 Spotlight 当作快捷指令
两者都能从系统进入 App,但配置和回调不同:
- 快捷指令主要依赖
AppShortcut和AppIntent; - Spotlight 还需要
CSSearchableItem或IndexedEntity索引; - Spotlight 点击最终通过
NSUserActivity回传。
误区二:SwiftUI 结果卡片就是 App 页面
ExampleIntentResultView 只负责展示 Intent 结果,不是旅游、酒店的业务页面。真正的页面仍然是 UIKit Controller。
误区三:配置了图标就等于实现了 SwiftUI
DisplayRepresentation(image:) 和 systemImageName 只是系统图标元数据。它们不创建 SwiftUI 界面。
误区四:Intent 执行后系统自动知道怎么导航
系统只能执行 perform(),不会猜测你的导航栈。必须在 Intent 中调用统一路由层,或者通过 TargetContentProvidingIntent 事件桥接到 UIKit。
误区五:有 @Parameter 就一定能搜索到
参数能否被系统解析,取决于:
-
AppEnum的展示名称和同义词; -
AppEntity的稳定 ID; -
defaultQuery; -
EntityStringQuery的匹配逻辑; - App Shortcuts 的短语模板;
- Spotlight 是否成功写入索引。
十七、权限、审核和生产建议
单纯使用 App Intents、App Shortcuts 和 Spotlight,不需要额外申请一个“Intent 权限”。但 Intent 执行后如果访问定位、相册、通讯录、麦克风或网络接口,仍需遵守对应权限和隐私说明。
上线前建议完成:
- 所有 Intent 都有清晰的标题、描述和参数提示;
- 不用无意义的随机实体 ID;
- 不在 Intent 里直接写复杂网络和页面逻辑;
- 后台 Intent 对网络失败、空数据和权限拒绝有明确错误;
- 前台导航不会绕过隐私协议和登录流程;
- Spotlight 索引失败不影响 App 正常启动;
- 删除或下线业务入口时同步删除旧索引;
- 真机验证冷启动、热启动、后台恢复和系统搜索点击;
- 确认 App 的实际名称与短语中的
applicationName一致; - 记录系统入口失败日志,但不要输出用户敏感数据。
十八、最终总结
本项目的完整链路可以概括为:
AppShortcutsProvider
-> AppIntent
-> AppEnum / AppEntity
-> EntityQuery
-> Spotlight Index
-> NSUserActivity
-> ExampleIntentNavManager
-> AppRouter
-> UIKit 业务页面
最值得复用的设计原则有三条:
- Intent 描述动作,Entity 描述对象,Query 负责查找。
- 系统入口不直接操作页面,统一交给 Router。
- 冷启动必须考虑隐私弹窗、根控制器尚未安装和 Scene 生命周期。
App Intents 的价值不在于“把 App 打开”,而在于把 App 的核心能力变成系统可理解、可搜索、可自动化调用的入口。只有当系统解析、实体查询、索引回调和业务导航全部连通时,用户说出的“打开 目标 App 的旅游”才真正具有产品价值。