Read between the strokes with PencilKit
SwiftUI & UI Frameworks 进阶 15m

使用 PencilKit 解读笔触之间的奥秘

Read between the strokes with PencilKit

2026年6月10日

在 Apple 官方观看视频

一句话判断

这场 Session 最值得关注的一件事是:Apple 终于把“备忘录”和“无边记”里那套极其好用的设备端手写识别引擎(PKStrokeRecognizer)开放给第三方了,而且直接做成了 Swift Actor。

这场 Session 讲了什么

以前第三方 App 用 PencilKit (铅笔套件) 基本只能当个高级画板,想做手写识别要么花大价钱买 MyScript 这种第三方 SDK,要么用 Vision 框架自己拼凑,体验跟系统原生差得远。现在 Apple 把压箱底的 PKStrokeRecognizer 拿出来了,全设备端运行,支持 29 种语言,连 Spotlight 索引和画布内搜索都给你配齐了。

除了识别,这次还打破了 PKCanvasView 的围墙。新的路径转换 API 允许 PKStrokePath 和贝塞尔曲线 (Bézier paths) 无损互转,加上 Stroke Slicing (笔画切割) 和稳定的 Stroke ID,这意味着你完全可以用自己的 Metal 渲染引擎画布,同时白嫖 Apple 顶级的识别和编辑能力。做笔记、教育和白板类 App 的开发者,这次是真的过年了。

值得深挖的点

PKStrokeRecognizer 的 Actor 设计与索引版本控制

Apple 把 PKStrokeRecognizer 设计成 Swift Actor 是个非常务实的决定。手写识别是典型的 CPU/NPU 密集型且耗时的操作,如果放在主线程或者用传统的 GCD 队列,状态同步和线程安全会让人头疼。Actor 天然隔离了内部状态,所有方法都是 async 的,逼着开发者用结构化并发来处理,避免了 UI 卡顿。

更亮眼的是 indexableContent (可索引内容) 和 recognizerVersion (识别器版本) 的配合。手写识别有歧义(比如 “1” 和 “l”),recognizedText() 只给最优解,而 indexableContent 会把所有可能的候选词拼起来给 Spotlight 用。但 Apple 的底层模型是会随系统更新而进化的,如果你把索引存到本地数据库,系统一升级,旧索引就不准了。recognizerVersion 就是为了解决这个 trade-off:把它和索引一起存下来,加载时比对版本号,不一样就触发后台重建索引。这种把“模型迭代”考虑进 API 设计的细节,确实老道。

打破黑盒:Bezier 互转与自定义渲染的解放

过去 PencilKit 最大的痛点是“强买强卖”:你想用它的模型,就必须用它的 PKCanvasView。如果你有自己的高性能渲染管线,PencilKit 就是个鸡肋。iOS 27 的 Bezier 路径互转彻底改变了这个局面。

PKStrokePath 底层是三次均匀 B 样条 (B-spline),现在 Apple 提供了官方算法将其与标准的贝塞尔曲线无损互转。这意味着你可以把 PencilKit 纯粹当成一个“数据模型和识别引擎”,用自己的渲染器画贝塞尔曲线,需要识别时再转回 PKStrokePath 喂给 PKStrokeRecognizer。配合新增的 Stroke Slicing (笔画切割) API,你甚至能自己实现复杂的编程式橡皮擦和笔画动画,而不用受制于系统默认的橡皮擦行为。PencilKit 从一个“全包圆的 UI 组件”变成了一个“灵活的底层工具”。

代码片段

1. 基础手写识别与防抖处理

场景:用户在画布上写字,实时在下方 Label 显示识别出的文本。

import PencilKit

// 初始化识别器,它是一个 Actor
let recognizer = PKStrokeRecognizer()

// 当 drawing 改变时调用此方法
func updateRecognizedText(for drawing: PKDrawing) async {
    // 将最新的绘图数据喂给识别器
    await recognizer.updateDrawing(drawing)
    
    // 获取最可能的识别结果
    // 注意:这是异步操作,不要在主线程同步等待
    let text = await recognizer.recognizedText()
    
    await MainActor.run {
        myLabel.text = text
    }
}

坑:updateDrawingrecognizedText 都是异步的,千万别在 PKCanvasViewDelegate 的回调里疯狂调用,一定要做防抖 (Debounce),否则会让 Actor 邮箱塞满,导致识别结果严重延迟。

2. 画布内文本搜索与高亮

场景:实现类似备忘录的“查找”功能,在画布上高亮匹配的手写单词。

import PencilKit
import UIKit

func highlightSearchResults(in drawing: PKDrawing, keyword: String) async {
    let recognizer = PKStrokeRecognizer()
    await recognizer.updateDrawing(drawing)
    
    // 搜索关键词,返回包含边界信息的数组
    let results = await recognizer.search(keyword)
    
    await MainActor.run {
        // 清除旧的高亮层
        clearHighlights()
        
        // 遍历结果,在对应边界绘制高亮框
        for result in results {
            drawHighlightBox(at: result.bounds)
        }
    }
}

坑:search 返回的 bounds 是基于 PKDrawing 的坐标系。如果你的画布有缩放 (zoom) 或平移 (pan),记得在绘制高亮框时应用相应的 CGAffineTransform,否则高亮框会错位。

最佳实践

  • 老项目迁移:如果你之前在用 Vision 框架的 VNRecognizeTextRequest 做手写识别,建议直接替换为 PKStrokeRecognizer。Vision 对印刷体很强,但对连笔手写的理解远不如 PencilKit 的原生引擎。
  • 新项目采用:把 PencilKit 当作数据层和识别层,UI 层可以根据需求选择 PKCanvasView 或自定义渲染。利用 PKStroke 新增的稳定 Identifiable (UUID) 来管理你的业务逻辑状态,这样在执行 Undo/Redo 时,你能精准追踪到具体是哪一笔发生了变化。
  • 性能避坑:在复杂绘图(几千个 Stroke)中使用 Stroke Slicing (笔画切割) 时,计算开销会呈指数级上升。尽量在用户停止绘制后的后台线程进行切割计算,或者限制单次切割的 Stroke 数量。另外,Simulator 目前只支持拉丁语系的识别,测试中日韩语言必须用真机。

还有什么值得关注

  • 稳定的 Stroke IDPKStroke 终于遵循了 Identifiable,Undo/Redo 或跨设备同步时,再也不用靠坐标和颜色去猜是不是同一笔了。
  • Wet-ink 渲染控制:现在可以手动调整 renderGroupID,控制哪些笔画在渲染时被视为“墨水未干”的状态,方便做特殊的混合效果。
  • 选区变化代理:新增 canvasViewSelectionDidChange 方法,终于能精准捕获用户在画布上框选内容的变化,不用再靠 KVO 去 hack 内部属性了。
PencilKit PKStrokeRecognizer Handwriting Recognition Swift Actor Bezier Path