WWDC Quick Look 💓 By SwiftGGTeam
Capture machine-readable codes and text with VisionKit

Capture machine-readable codes and text with VisionKit

观看原视频

Highlight

iOS 上做实时数据扫描,之前的方案要么用 AVFoundation 的 metadata output(只支持机器码),要么把 AVFoundation 和 Vision 组合起来(代码量大,坐标转换麻烦)。iOS 16 的 DataScannerViewController 把这些全部封装成一个开箱即用的 ViewController。


核心内容

做一个扫码界面,过去有两条路。

只识别二维码、条形码,可以用 AVFoundation,把输入、输出接到 capture session,再拿到 AVMetadataObject。如果还要识别文字,就要把 AVFoundation 和 Vision 接起来:相机输出 sample buffer,Vision 跑文字或条码识别请求,再把 observation 映射回界面。

麻烦点不在识别本身。麻烦点在实时相机预览、用户授权、点击对焦、双指缩放、识别项高亮,以及从 image space 到 Vision 坐标再到 view 坐标的转换。

DataScannerViewController 是 iOS 16 的新选择。它在 VisionKit 里,把 AVFoundation 和 Vision 封装成一个 UIViewController 子类。你指定要识别的类型,present 它,然后调用 startScanning()。相机预览、引导文字、系统高亮、点击对焦和缩放都由系统处理。

它适合的场景很直接:仓库拣货、收银、票据录入、现场盘点。这些 App 的主要工作是扫到数据之后的业务流程。Data Scanner 让扫描部分变短。


详细内容

先判断设备和权限

04:01)Data Scanner 不是所有设备都支持。Apple 在演讲中说明,2018 年及之后、带 Apple Neural Engine 的 iPhone 和 iPad 支持它。

因此入口按钮不要永远显示。先用 isSupported 判断硬件支持,再用 isAvailable 判断当前是否可用。isAvailable 会受相机授权和设备限制影响,例如 Screen Time 里的相机访问限制。

import VisionKit

if DataScannerViewController.isSupported,
   DataScannerViewController.isAvailable {
    showScanButton()
} else {
    hideScanButton()
}

关键点:

  • import VisionKit 引入 DataScannerViewController
  • isSupported 用来判断设备硬件是否支持实时数据扫描。
  • isAvailable 用来判断当前状态是否允许扫描,包括相机权限和系统限制。
  • 不满足条件时隐藏入口,避免用户进入一个无法工作的扫描流程。

还要在 Info.plist 里添加 camera usage description。演讲提醒,这段文案要解释为什么需要相机,让用户知道自己在授权什么。

创建扫描器

04:40)创建扫描器时,最重要的参数是 recognizedDataTypes。它告诉系统要找什么。

官方示例先扫描二维码,也展示了如何改成文字、指定语言,或只找 URL。

import VisionKit

// Specify the types of data to recognize
let recognizedDataTypes:Set<DataScannerViewController.RecognizedDataType> = [
    .barcode(symbologies: [.qr]),
    // uncomment to filter on specific languages (e.g., Japanese)
    // .text(languages: ["ja"])
    // uncomment to filter on specific content types (e.g., URLs)
    // .text(textContentType: .URL)
]

// Create the data scanner, present it, and start scanning!
let dataScanner = DataScannerViewController(recognizedDataTypes: recognizedDataTypes)
present(dataScanner, animated: true) {
    try? dataScanner.startScanning()
}

关键点:

  • Set<DataScannerViewController.RecognizedDataType> 可以同时包含机器码和文字。
  • .barcode(symbologies: [.qr]) 把机器码识别限制为 QR code。
  • .text(languages: ["ja"]) 可以给文字识别传语言提示,演讲中特别提到日语和韩语是 iOS 16 Live Text 新增支持的语言。
  • .text(textContentType: .URL) 可以让扫描器只寻找特定语义的文字,例如 URL。
  • DataScannerViewController(recognizedDataTypes:) 创建的是一个普通 view controller,可以全屏、sheet,或加入自定义层级。
  • startScanning() 在 presentation 完成后调用,开始从实时视频流里寻找数据。

06:29)初始化参数还可以控制体验。qualityLevel 可选 balancedfastaccurate。Apple 建议从 balanced 开始。大而清楚的文字可以用 fast,很小的二维码或序列号可以用 accurate

let dataScanner = DataScannerViewController(
    recognizedDataTypes: recognizedDataTypes,
    qualityLevel: .balanced,
    recognizesMultipleItems: true,
    isHighFrameRateTrackingEnabled: true,
    isPinchToZoomEnabled: true,
    isGuidanceEnabled: true,
    isHighlightingEnabled: true
)

关键点:

  • qualityLevel: .balanced 是大多数场景的起点。
  • recognizesMultipleItems: true 允许一帧里识别多个项目,适合一次扫多个条形码。
  • isHighFrameRateTrackingEnabled 让自定义高亮更紧地跟随目标移动。
  • isPinchToZoomEnabled 控制用户是否可以双指缩放。
  • isGuidanceEnabled 显示顶部引导标签,帮助用户对准目标。
  • isHighlightingEnabled 开启系统高亮;如果要自己画高亮,可以关闭它。

处理用户点中的项目

08:11)如果 App 需要知道用户点中了什么,就设置 delegate。

// Specify the types of data to recognize
let recognizedDataTypes:Set<DataScannerViewController.RecognizedDataType> = [
    .barcode(symbologies: [.qr]),
    .text(textContentType: .URL)
]

// Create the data scanner, present it, and start scanning!
let dataScanner = DataScannerViewController(recognizedDataTypes: recognizedDataTypes)
dataScanner.delegate = self
present(dataScanner, animated: true) {
    try? dataScanner.startScanning()
}

关键点:

  • 这里同时识别 QR code 和 URL 文本。
  • dataScanner.delegate = self 让当前对象接收扫描器事件。
  • delegate 会收到点击、识别项新增、更新、移除等回调。
  • present 完成后再启动扫描,避免扫描器尚未进入界面就开始工作。

08:19)用户点击识别项时,delegate 收到 RecognizedItem。它是一个 enum,可能是文字,也可能是条码。

func dataScanner(_ dataScanner: DataScannerViewController, didTapOn item: RecognizedItem) {
    switch item {
    case .text(let text):
        print("text: \(text.transcript)")
    case .barcode(let barcode):
        print("barcode: \(barcode.payloadStringValue ?? "unknown")")
    default:
        print("unexpected item")
    }
}

关键点:

  • didTapOn 在用户点击某个识别项时触发。
  • .text(let text) 分支读取文字识别结果。
  • text.transcript 是识别出的字符串。
  • .barcode(let barcode) 分支处理机器码。
  • barcode.payloadStringValue 只有 payload 能转成字符串时才有值,所以示例用 ?? "unknown" 兜底。
  • RecognizedItem 还带有稳定的 idbounds,可用于跟踪和绘制高亮。

自定义高亮

08:54)每个 RecognizedItem 都有 bounds。演讲特别说明,这个值由四个角点组成;因为透视变换下文字或条码可能呈梯形,所以不能把它当作普通矩形处理。

如果系统高亮不符合 App 风格,可以用三组 delegate 方法自己画。

09:11)识别项刚出现时,创建高亮视图,并把它加到 overlayContainerView

// Dictionary to store our custom highlights keyed by their associated item ID.
var itemHighlightViews: [RecognizedItem.ID: HighlightView] = [:]

// For each new item, create a new highlight view and add it to the view hierarchy.
func dataScanner(_ dataScanner: DataScannerViewController, didAdd addedItems: [RecognizedItem], allItems: [RecognizedItem]) {
    for item in addedItems {
        let newView = newHighlightView(forItem: item)
        itemHighlightViews[item.id] = newView
        dataScanner.overlayContainerView.addSubview(newView)
    }
}

关键点:

  • itemHighlightViewsRecognizedItem.ID 作为 key,把识别项和自定义高亮绑定起来。
  • didAdd 在新项目被识别时调用。
  • newHighlightView(forItem:) 根据识别项创建高亮视图。
  • item.id 在项目生命周期内保持稳定,直到它离开画面。
  • overlayContainerView 位于相机预览上方,适合承载自定义高亮。

09:37)项目或相机移动时,更新高亮位置。

// Animate highlight views to their new bounds
func dataScanner(_ dataScanner: DataScannerViewController, didUpdate updatedItems: [RecognizedItem], allItems: [RecognizedItem]) {
    for item in updatedItems {
        if let view = itemHighlightViews[item.id] {
            animate(view: view, toNewBounds: item.bounds)
        }
    }
}

关键点:

  • didUpdate 在识别项移动、相机移动,或文字转写结果变化时调用。
  • updatedItems 只包含发生变化的项目。
  • 通过 item.id 找回已有高亮视图。
  • item.bounds 是新的四角坐标,用它驱动高亮动画。

10:03)项目离开画面时,移除对应高亮。

// Remove highlights when their associated items are removed.
func dataScanner(_ dataScanner: DataScannerViewController, didRemove removedItems: [RecognizedItem], allItems: [RecognizedItem]) {
    for item in removedItems {
        if let view = itemHighlightViews[item.id] {
            itemHighlightViews.removeValue(forKey: item.id)
            view.removeFromSuperview()
        }
    }
}

关键点:

  • didRemove 在项目不再可见时调用。
  • removedItems 给出刚刚离开场景的识别项。
  • removeValue(forKey:) 清掉字典里的引用。
  • removeFromSuperview() 把高亮从界面上移除。
  • allItems 参数包含当前仍然被识别的全部项目;文字识别场景下,这个数组按自然阅读顺序排列。

拍照和 AsyncStream

10:54)扫描器也可以拍一张高质量静态图。示例把它保存到相册。

// Take a still photo and save to the camera roll
if let image = try? await dataScanner.capturePhoto() {
    UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
}

关键点:

  • capturePhoto() 是异步方法。
  • 成功时返回 UIImage
  • 示例用 UIImageWriteToSavedPhotosAlbum 保存到系统相册。
  • 这个能力适合把扫描结果和现场照片一起存档,例如票据、货架或设备铭牌。

11:10)如果不需要自定义高亮,也可以不用三组 delegate 方法。recognizedItems 提供一个持续更新的 AsyncStream

// Send a notification when the recognized items change.
var currentItems: [RecognizedItem] = []

func updateViaAsyncStream() async {
    guard let scanner = dataScannerViewController else { return }

    let stream = scanner.recognizedItems
    for await newItems: [RecognizedItem] in stream {
        let diff = newItems.difference(from: currentItems) { a, b in
            return a.id == b.id
        }

        if !diff.isEmpty {
            currentItems = newItems
            sendDidChangeNotification()
        }
    }
}

关键点:

  • currentItems 保存上一轮识别结果。
  • dataScannerViewController 不存在时直接返回。
  • scanner.recognizedItems 是持续输出 [RecognizedItem] 的 async stream。
  • for await 随着场景变化接收新数组。
  • difference(from:)id 比较项目是否相同。
  • 只有 diff 非空时才更新状态并发送通知。

核心启发

1. 仓库拣货扫码器

  • 做什么:一次识别货架上的多个 QR code 或条形码,点中某个货品后进入拣货流程。
  • 为什么值得做recognizesMultipleItems 支持同一画面内多个识别项,didTapOn 能拿到用户选中的项目。
  • 怎么开始:把 recognizedDataTypes 设为 .barcode(...),开启多项目识别,用 barcode.payloadStringValue 匹配库存系统里的 SKU。

2. 票据 URL 和电话号码提取

  • 做什么:用相机扫纸质票据或海报,只提取 URL 或电话号码。
  • 为什么值得做:Data Scanner 支持文本 content type 过滤,减少无关文字进入业务流程。
  • 怎么开始:使用 .text(textContentType: .URL) 或对应的文本语义类型,用户点击识别项后读取 text.transcript

3. 现场资产盘点

  • 做什么:扫描设备铭牌上的小号序列号,并拍下现场照片留档。
  • 为什么值得做qualityLevel: .accurate 适合小项目,capturePhoto() 可以保存高质量静态图。
  • 怎么开始:初始化时选择 .accurate,识别到序列号后调用 await dataScanner.capturePhoto(),把图片和识别文本一起写入记录。

4. 品牌化扫描界面

  • 做什么:关闭系统高亮,用自己的颜色、动画和形状标出识别项。
  • 为什么值得做overlayContainerViewdidAdddidUpdatedidRemove 提供了完整生命周期。
  • 怎么开始:用 RecognizedItem.ID 维护高亮字典,在 didUpdate 中根据 item.bounds 更新四角位置。

5. 实时文本看板

  • 做什么:把相机当前看到的文字实时汇总到旁边的列表或调试面板。
  • 为什么值得做recognizedItemsAsyncStream,适合把识别变化接入 Swift Concurrency 工作流。
  • 怎么开始:在 task 中 for await 读取 scanner.recognizedItems,用 difference(from:) 过滤没有变化的帧。

关联 Session

评论

GitHub Issues · utterances