iOS App Intents 技术实践:让系统入口直接进入 App

本文基于一个 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 进入旅游页面。

这里要区分两件事:

  1. 系统是否能理解用户想做什么:由 Intent、参数、同义词和 Query 负责。
  2. 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 内必须直接返回 PropertysortingOptions 的 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 桥接

ShowsSnippetViewonAppIntentExecution 使用 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 可能处于三种状态:

  1. App 已在前台,主控制器已经存在;
  2. App 在后台,进程还在,但页面需要恢复;
  3. 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

在真机上按以下顺序测试:

  1. 打开“快捷指令”App;
  2. 搜索“目标 App”或“打开功能”;
  3. 添加“打开 目标 App 功能”;
  4. 选择“旅游”或“酒店民宿”;
  5. 运行快捷指令;
  6. 确认 App 被唤起并进入对应页面。

如果只能唤起 App,不能进入页面,优先检查:

  • ExampleIntentNavManager 是否在主线程执行;
  • setupAppIntentBridge() 是否已经挂载;
  • 主页面是否仍在隐私弹窗阶段;
  • pendingRoute 是否在主页面完成后消费;
  • Router 是否使用了当前有效的导航控制器。

2. Spotlight

Spotlight 是系统搜索入口,不是 App 资源库。测试时在主屏幕向下滑,搜索以下完整关键词:

目标 App 旅游
目标 App 酒店
目标 App 求职

首次安装或首次启动后,系统索引可能有延迟。确认日志中出现索引成功信息后,再测试搜索。若只有 App 名称没有服务结果,检查 CSSearchableItemtitlekeywordsuniqueIdentifierexpirationDate

3. Siri 或语音入口

测试短语必须包含 App 名称,例如:

打开 目标 App 的旅游
在 目标 App 里打开酒店
让目标 App 推荐服务

不要把“只唤起 App”误认为已经完成业务动作。真正的验收标准是:

系统解析参数 -> App 前台运行 -> 主页面准备完成 -> Router 推入目标页面

4. 冷启动和热启动

至少覆盖以下组合:

场景 预期结果
App 前台运行时执行 直接进入目标页面
App 后台运行时执行 恢复 App 后进入目标页面
App 被杀掉后执行 冷启动,完成初始化后进入目标页面
首次安装且隐私弹窗未处理 暂存路由,不绕过隐私同意
搜索结果过期或 ID 不存在 安全忽略,不崩溃

十六、常见误区

误区一:把 Spotlight 当作快捷指令

两者都能从系统进入 App,但配置和回调不同:

  • 快捷指令主要依赖 AppShortcutAppIntent
  • Spotlight 还需要 CSSearchableItemIndexedEntity 索引;
  • 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 业务页面

最值得复用的设计原则有三条:

  1. Intent 描述动作,Entity 描述对象,Query 负责查找。
  2. 系统入口不直接操作页面,统一交给 Router。
  3. 冷启动必须考虑隐私弹窗、根控制器尚未安装和 Scene 生命周期。

App Intents 的价值不在于“把 App 打开”,而在于把 App 的核心能力变成系统可理解、可搜索、可自动化调用的入口。只有当系统解析、实体查询、索引回调和业务导航全部连通时,用户说出的“打开 目标 App 的旅游”才真正具有产品价值。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容