WWDC Quick Look 💓 By SwiftGGTeam
Best practices for integrating visual intelligence in your app

Best practices for integrating visual intelligence in your app

观看原视频

Highlight

Apple 在 iOS、iPadOS 和 macOS 上开放了 Visual Intelligence 的 Image Search 接入能力。开发者通过 App Intents 定义业务实体,用 Vision 框架在端上完成图像相似度匹配,即可让用户在相机或截图中框选内容时直接看到 App 的搜索结果,并一键跳转到对应页面。

核心内容

从”只能看”到”能参与”

以前 Visual Intelligence 是系统独占功能。用户拍植物、查商品,结果页里只有系统提供的信息,第三方 App 插不上手。WWDC26 把这个能力开放了——你的 App 可以成为 Visual Intelligence 的搜索结果来源之一。

想象一个场景:朋友发来一张专辑封面截图,你想立刻在收藏里找到这张专辑开始播放。以前的做法是保存图片、打开 App、手动搜索。现在用户只需框选截图中的封面,你的 App 就能直接返回匹配结果,点击即播。

这个流程能跑通,靠的是三个组件的配合:App Entity 定义要返回什么内容,IntentValueQuery 接收图像并返回结果,OpenIntent 把用户带到正确的页面。

跨平台,同一套代码

10:08

Visual Intelligence 今年扩展到了 iPadOS 和 macOS。好消息是,IntentValueQuery、Entity 和 OpenIntent 的代码在三端通用,不需要为每个平台单独写一套。

但要注意使用场景的差异。iOS 上用户主要通过相机拍摄物理世界——唱片封面、演唱会海报。macOS 和 iPad 上则更多是截图,框选屏幕上的数字内容。Mac 上的截图像素尺寸可能远大于 iPhone,直接处理原始像素会吃光内存,需要先做缩放。

不只返回一种结果

11:47

用户框选一张海报,可能既想听专辑,又想了解相关演唱会。@UnionValue 让单个查询返回多种实体类型成为可能。你可以把专辑和演唱会混排在一个结果列表里,每种类型对应自己的 OpenIntent,点击后进入不同的页面。

系统商店的数据回流

14:27

Visual Intelligence 不只是”你的 App 给系统提供结果”这一个方向。系统也能把提取到的数据写入共享存储,你的 App 读取即可。

比如用户用 Visual Intelligence 识别了社交媒体上的演唱会信息并添加到日历,你的 App 通过 EventKit 监听日历变化,就能自动在”即将开始的演唱会”列表里显示这条记录。Contacts、HealthKit 也是同样的模式。

详细内容

定义 App Entity

03:21

第一步是用 App Intents 框架定义要返回的实体。以下是一个音乐 App 中专辑实体的完整定义:

import AppIntents

struct AlbumEntity: AppEntity {
    var id: String
    @Property var name: String
    @Property var artistName: String
    var coverArtData: Data

    var displayRepresentation: DisplayRepresentation {
        DisplayRepresentation(
            title: "\(name)",
            subtitle: "\(artistName)",
            image: .init(data: coverArtData)
        )
    }

    static let defaultQuery = AlbumEntityQuery()
    static var typeDisplayRepresentation: TypeDisplayRepresentation { "Album" }
}

struct AlbumEntityQuery: EntityQuery {
    @Dependency var catalog: AlbumCatalog

    func entities(for identifiers: [String]) async throws -> [AlbumEntity] {
        catalog.albums(for: identifiers)
    }
}

关键点:

  • DisplayRepresentation 控制在结果页中如何展示。系统给的空间大约三行文字加一张缩略图,把最重要的识别信息放在标题和副标题里。
  • 如果返回多个结果,缩略图会出现在双列布局中,建议提供 200x200 左右的小图,不要用原图 URL。
  • 如果只返回一个结果,图片会占满结果页整宽,这时可以用分辨率稍高的图。

实现 IntentValueQuery 接收图像输入

05:39

Visual Intelligence 通过 IntentValueQuery 协议把捕获的图像传给 App。输入类型是 SemanticContentDescriptor,里面包含 pixelBuffer

import AppIntents
import VisualIntelligence

struct SearchHandler: IntentValueQuery {
    @Dependency var catalog: AlbumCatalog
    @Dependency var concertFinder: ConcertFinder

    func values(for input: SemanticContentDescriptor) async throws -> [VisualSearchResult] {
        guard let pixelBuffer = input.pixelBuffer else {
            return []
        }

        let albums = try await catalog.search(matching: pixelBuffer)

        return albums.map { VisualSearchResult.album($0) }
    }
}

关键点:

  • values(for:) 接收 SemanticContentDescriptor,从中提取 pixelBuffer
  • 查询有严格的执行时间限制,不要做网络请求或重度计算。
  • 如果没有匹配结果,返回空数组即可,系统会处理空状态的展示。

用 Vision 框架做本地图像匹配

06:24

端上搜索的核心是 GenerateImageFeaturePrintRequest,它把图像转成紧凑的特征向量(feature print),通过计算向量距离判断相似度:

import Vision

@Observable
class AlbumCatalog {
    static let shared = AlbumCatalog()

    struct CatalogEntry: Sendable {
        let album: AlbumEntity
        let featurePrint: FeaturePrintObservation
    }

    private(set) var entries: [CatalogEntry] = []

    private func generateFeaturePrint(for image: CGImage) async throws -> FeaturePrintObservation {
        let request = GenerateImageFeaturePrintRequest()
        let result = try await request.perform(on: image)
        return result
    }
}

搜索时,先为输入图像生成 feature print,再与预计算好的目录比对:

func search(matching pixelBuffer: CVReadOnlyPixelBuffer, limit: Int = 10, maxDistance: Double = 1.0) async throws -> [AlbumEntity] {
    var cgImage: CGImage?
    _ = pixelBuffer.withUnsafeBuffer {
        VTCreateCGImageFromCVPixelBuffer($0, options: nil, imageOut: &cgImage)
    }
    guard let cgImage else { return [] }

    let queryPrint = try await generateFeaturePrint(for: cgImage)

    return try entries.compactMap { entry -> (album: AlbumEntity, distance: Double)? in
        let distance = try queryPrint.distance(to: entry.featurePrint)
        guard distance <= maxDistance else { return nil }
        return (entry.album, distance)
    }
    .sorted { $0.distance < $1.distance }
    .prefix(limit)
    .map { $0.album }
}

关键点:

  • VTCreateCGImageFromCVPixelBufferCVPixelBuffer 转成 CGImage
  • FeaturePrintObservation.distance(to:) 计算两个特征向量的距离,值越小越相似。
  • maxDistance 过滤掉不相关的结果,避免返回噪声。
  • 目录中的 feature print 必须预计算,查询时只做距离比较,保证响应速度。
  • Mac 上的截图尺寸可能很大,建议在转 CGImage 之前先缩放,防止内存峰值。

用 OpenIntent 跳转到正确页面

08:27

用户点击搜索结果后,系统调用对应的 OpenIntent

import AppIntents

struct OpenAlbumIntent: OpenIntent {
    static let title: LocalizedStringResource = "Open Album"

    @Parameter(title: "Album")
    var target: AlbumEntity

    @Dependency var appState: AppState

    func perform() async throws -> some IntentResult {
        await appState.openAlbum(id: target.id)
        return .result()
    }
}

关键点:

  • perform() 在 App 进入前台的过程中执行,只做轻量导航操作。
  • 重度数据加载留到视图出现后再做,避免阻塞启动流程。
  • 如果你之前为 Siri 集成已经写过 OpenIntent,可以直接复用。

返回多种结果类型

12:05

@UnionValue 让单个查询返回多种实体:

@UnionValue
enum VisualSearchResult {
    case album(AlbumEntity)
    case concert(ConcertEntity)
}

struct OpenConcertIntent: OpenIntent {
    static let title: LocalizedStringResource = "Open Concert"

    @Parameter(title: "Concert")
    var target: ConcertEntity

    @Dependency var appState: AppState

    func perform() async throws -> some IntentResult {
        await appState.openConcert(id: target.id)
        return .result()
    }
}

更新查询逻辑,把两种结果混排:

struct SearchHandler: IntentValueQuery {
    @Dependency var catalog: AlbumCatalog
    @Dependency var concertFinder: ConcertFinder

    func values(for input: SemanticContentDescriptor) async throws -> [VisualSearchResult] {
        guard let pixelBuffer = input.pixelBuffer else {
            return []
        }

        let albums = try await catalog.search(matching: pixelBuffer)
        let artists = albums.map { $0.artistName }
        let concerts = await concertFinder.findNearby(byArtists: artists)

        return albums.map { VisualSearchResult.album($0) }
            + concerts.map { VisualSearchResult.concert($0) }
    }
}

关键点:

  • @UnionValue 枚举的每种 case 包裹一个 AppEntity。
  • 每种实体类型需要独立的 OpenIntent。
  • 结果混排顺序由你控制,建议把最相关的结果放前面。

提供 App 内搜索的兜底入口

13:13

如果系统结果页没有用户想要的内容,可以提供一个”在 App 内继续搜索”的入口:

@AppIntent(schema: .visualIntelligence.semanticContentSearch)
struct SemanticContentSearchIntent: AppIntent {
    static let title: LocalizedStringResource = "Search in app"
    static let openAppWhenRun: Bool = true

    var semanticContent: SemanticContentDescriptor
    @Dependency var catalog: AlbumCatalog
    @Dependency var concertFinder: ConcertFinder
    @Dependency var appState: AppState

    func perform() async throws -> some IntentResult {
        guard let pixelBuffer = semanticContent.pixelBuffer else { return .result() }
        let albums = try await catalog.search(matching: pixelBuffer)
        let artists = albums.map { $0.artistName }
        let concerts = await concertFinder.findNearby(byArtists: artists)
        await appState.openSearch(albums: albums, concerts: concerts)
        return .result()
    }
}

关键点:

  • 使用 .visualIntelligence.semanticContentSearch schema,系统会自动传入 semanticContent
  • openAppWhenRun: true 确保 App 被唤起。
  • 在 App 内预填充搜索结果,用户不用从零开始。

通过 EventKit 接收系统日历数据

15:24

用户通过 Visual Intelligence 添加到日历的事件,App 可以通过 EventKit 读取:

import EventKit

@Observable
class UpcomingConcertManager {
    private let eventStore = EKEventStore()
    var upcomingConcerts: [EKEvent] = []
    var authorizationStatus: EKAuthorizationStatus = .notDetermined

    func requestAccessAndFetch() async throws {
        let granted = try await eventStore.requestFullAccessToEvents()
        guard granted else {
            authorizationStatus = .denied
            return
        }
        authorizationStatus = .fullAccess
        await fetchUpcomingConcerts()

        for await _ in NotificationCenter.default.notifications(named: .EKEventStoreChanged) {
            await fetchUpcomingConcerts()
        }
    }

    func fetchUpcomingConcerts() async {
        let predicate = eventStore.predicateForEvents(
            withStart: .now,
            end: .now.addingTimeInterval(90 * 24 * 60 * 60),
            calendars: nil
        )

        let events = eventStore.events(matching: predicate)

        upcomingConcerts = events.filter { event in
            AlbumCatalog.shared.entries.contains { entry in
                event.title?.localizedCaseInsensitiveContains(entry.album.artistName) == true
            }
        }
    }
}

关键点:

  • requestFullAccessToEvents() 请求完整日历访问权限。
  • 监听 .EKEventStoreChanged 通知,Visual Intelligence 添加的新事件会自动触发刷新。
  • predicateForEvents 查询未来 90 天内的事件,再按业务逻辑过滤。

核心启发

1. 做一个”拍照找商品”的电商入口

  • 做什么:用户拍下杂志上的商品照片,App 直接返回匹配的商品卡片。
  • 为什么值得做:Visual Intelligence 把相机变成了免唤醒的搜索入口,用户不用打字、不用切换 App。
  • 怎么开始:用 GenerateImageFeaturePrintRequest 为商品图预计算 feature print,存到本地数据库。定义 ProductEntity,实现 IntentValueQuery 做相似度匹配。

2. 做一个”截图识别票务信息”的演出助手

  • 做什么:用户截图社交媒体上的演唱会海报,Visual Intelligence 识别后添加到日历,App 自动在”即将观看”列表中显示,并推荐预热歌单。
  • 为什么值得做:系统商店集成让数据自动回流,App 不需要自己写 OCR 解析截图。
  • 怎么开始:监听 EventKit 的 .EKEventStoreChanged,过滤出与 App 业务相关的事件。

3. 做一个”框选屏幕文字”的跨应用笔记工具

  • 做什么:用户在任意 App 中框选一段文字或图片,你的笔记 App 接收 SemanticContentDescriptor,提取内容并创建笔记条目。
  • 为什么值得做:macOS 上的截图是主要输入源,桌面工作流中跨应用收集信息的痛点很真实。
  • 怎么开始:结合 Vision 的文本识别(RecognizeTextRequest)从 pixelBuffer 提取文字内容。

4. 做一个”拍照识别药品”的健康记录工具

  • 做什么:用户拍摄血压计、血糖仪的屏幕,Visual Intelligence 把读数写入 HealthKit,App 自动同步到健康档案。
  • 为什么值得做:WWDC26 提到医疗设备日志是 Visual Intelligence 的新能力,HealthKit 读取即可,不需要自己解析数字。
  • 怎么开始:用 HKHealthStore 查询相关类型的健康数据,监听数据变化。

5. 做一个”框选海报”的多媒体发现引擎

  • 做什么:用户框选电影海报,App 返回电影信息、原声带、附近影院排片,三种结果混排在一个列表里。
  • 为什么值得做@UnionValue 让单一查询返回多种实体,一个动作触发多条业务线。
  • 怎么开始:定义包含电影、音乐、影院三种 case 的 @UnionValue 枚举,每种配独立的 OpenIntent。

关联 Session

评论

GitHub Issues · utterances