最近在 Hacker News 上看到一个很有意思的项目方向:Show HN: Visually Precise AI Tutoring on iOS。它核心不是再做一款“拍照搜题”App,而是试图把 AI 辅导从“一段文字答案”升级成“能在屏幕上精确指向问题位置”的视觉级交互。这个方向其实击中了当前 AI 教育应用一个很明显的痛点:大多数辅导反馈停留在文本语义层,模型能告诉你“哪里错了”,但没法在真实屏幕上给你画出来。
本文不评价具体产品,而是把它拆成一条 iOS 工程师可以直接上手的技术链路:屏幕内容采集 → 视觉结构化 → 多模态大模型推理 → 视觉结果渲染。无论你是想复刻类似应用,还是想给自己的教育类 App 增加 AI 辅导能力,都可以按这条链路一步步落地。全文包含可运行的 Swift 代码示例、权限处理、坐标系转换、常见坑点和工程建议,适合已经掌握 Swift 基础、想深入 AI + iOS 方向的同学阅读和复用。
1. “视觉精确的 AI 辅导”是什么
1.1 从文本问答到“看见屏幕”
常规的 AI 辅导产品,交互流程一般是:用户拍一道题或截一张图,上传给大模型,模型返回解析步骤和最终答案。这种方式对“静态题目”效果不错,但对“动态操作类学习场景”就有明显短板:用户在 App 里点错了按钮、配置错了参数、触发了一个报错弹窗,此时单纯把截图发给模型,模型只能看到一张孤立图片,无法知道你刚才做了什么操作,也无法在屏幕上精确标记“这个红色区域就是你出错的地方”。
“视觉精确”的 AI 辅导,核心变化是把屏幕本身作为上下文。应用持续或按需采集屏幕内容,经过结构化处理之后,连同用户问题一起交给多模态大模型。模型不仅返回文字步骤,还能返回屏幕上的目标区域坐标。iOS 端拿到坐标后,在屏幕上渲染高亮框、箭头、序号标注,甚至引导用户点击下一个位置。这样一来,AI 的回答就从“该怎么做”扩展成了“看这里,这就是问题所在,然后点这个按钮”。
1.2 视觉精确的三个层次
理解了方向之后,可以把“视觉精确”拆成三个可量化的层次,后续架构设计都围绕它们展开。
第一层是空间精确。AI 必须能定位到屏幕上的具体元素,比如“第二行公式中的 x 符号”“当前页面右上角的保存按钮”“报错弹窗中的关闭图标”。这些信息最终要落到一个矩形区域或坐标点,而不是一句含糊的“红色字体部分”。
第二层是语义精确。模型需要理解当前屏幕上下文。同样是“保存失败”四个字,出现在表单页和出现在代码编辑器里,原因完全不同。视觉精确辅导要求模型把文字识别结果、按钮状态、输入框内容、页面结构组合成完整语义,而不能只看单独的 OCR 文本。
第三层是时序精确。优秀辅导不是单次问答,而是多轮交互。学生点击了某个按钮后,屏幕状态发生变化,AI 需要感知这次变化,并基于“前后状态差异”继续指导。例如学生第一次选错了选项,界面出现错误提示,AI 下一次回答就要能引用这个错误提示区域,而不是重复之前的内容。
1.3 适用场景与读者定位
这类技术适合三类场景:一是数学、物理等理科题目辅导,模型可以精确指出公式推导中从第几行开始出错;二是软件操作类教学,例如教用户配置证书、处理 Xcode 打包报错、操作复杂后台系统,AI 可以直接高亮界面按钮;三是编程入门辅导,用户在 iOS 模拟器或在线编辑器里运行代码,AI 定位控制台报错并高亮对应代码行。
本文面向的读者是:有一定 Swift 和 Xcode 使用经验、想进入 AI 应用开发方向、或者正在设计教育类产品交互的开发者。不需要你提前掌握机器学习和 Vision 框架细节,但建议你对 SwiftUI 或 UIKit 的 UI 层级、异步网络请求、JSON 解析有基本概念。读完本文后,你能搭建出一条最小可用链路,并知道每一步的常见坑在哪里。
2. iOS 端视觉 AI 应用的整体架构
2.1 核心链路:采集 → 识别 → 推理 → 渲染
整套系统可以抽象为四个模块,串成如下链路:
屏幕采集(截图 / ReplayKit) ↓ 视觉结构化(Vision OCR、元素检测) ↓ 大模型推理(多模态大模型 / 文本模型) ↓ 结果渲染(覆盖层高亮、坐标标注、步骤展示)四个模块职责非常清晰:
- 屏幕采集层:负责拿到当前屏幕的 UIImage 或视频帧。这里需要区分应用内截图和系统级屏幕录制两种方式,权限模型完全不同。
- 视觉结构化层:把像素级图片转成 AI 可读的文本与坐标信息。例如 OCR 识别出屏幕上所有文字及其位置,这一步是实现“空间精确”的关键。
- 大模型推理层:把“用户问题 + 视觉结构化结果 + 历史对话”一起发送给大模型,让模型返回带坐标引用的结构化答案。
- 结果渲染层:把模型返回的归一化坐标转换回屏幕坐标,在 UI 上绘制高亮框、文字气泡、操作引导等。
这四个模块可以分别开发、分别测试,最后再串联。这也是我在实际项目中比较推荐的做法:不要一开始就追求完整的 Broadcast Extension 实时链路,先做“截图 + 本地识别 + 在线推理 + 静态标注”,跑通之后再升级。
2.2 技术选型建议
围绕四个模块,iOS 生态内有比较成熟的选型:
- UI 框架:SwiftUI 为主,UIKit 兜底。渲染覆盖层用 SwiftUI 的 Canvas 或 overlay 很直观,成本低。
- 屏幕采集:最简单的是应用内窗口截图,使用
UIGraphicsImageRenderer即可;如果要采集其他 App 的屏幕,则必须用 ReplayKit + Broadcast Upload Extension。 - 视觉识别:优先使用 Vision 框架。它能做文字识别(OCR)、人脸检测、矩形检测、图片分类。对于通用元素检测,甚至可以用 Vision 的
VNRecognizeTextRequest先提取文本坐标,再用VNDetectRectanglesRequest获取区域。 - 大模型接入:通过 URLSession 调用国内或海外主流大模型的多模态接口。注意不同模型的接口格式、图片编码方式、JSON 输出能力差异较大,建议在服务端做一层封装,客户端只面向统一的协议。
- 结果渲染:归一化坐标 + SwiftUI Shape 绘制。不要直接使用模型返回的像素坐标,而是约定一个归一化坐标体系,适配不同屏幕尺寸。
2.3 环境准备与隐私前提
本文示例基于 Xcode 15 和 iOS 17 环境,Swift 版本为 5.x。虽然代码用到了 iOS 17 才完善的一些 API,但核心思路在 iOS 15 上也能实现。版本需要根据你的项目实际情况调整,本文示例以常见环境为例,重点演示配置思路。
动手开发前,有两个隐私前提必须想清楚:
第一,屏幕内容极其敏感。无论是应用内截图还是系统级屏幕录制,都必须在用户知情的前提下进行。应用内截图只影响自己 App 范围,相对简单;系统级录制会弹出系统级“正在共享屏幕”提示,产品上要设计清晰的说明文案。
第二,教育类产品如果面向未成年人,还要额外考虑数据最小化原则。能只上传局部截图就尽量不要整屏上传,能本地完成 OCR 就不要把原始截图发给服务器。建议在客户端完成视觉结构化,只把文本和坐标发送给大模型,从源头减少隐私风险。
3. 屏幕内容采集:截图还是屏幕录制
3.1 两种主流方案对比
在 iOS 上获取屏幕内容,没有真正意义上“万能静默截屏”的公共 API。系统对用户隐私的保护很强,开发者只能在系统允许的框架内操作,因此不同业务场景方案不同。
第一种方案是应用内截图。这种方案最稳定,也最容易实现。它只捕获我们自己的 App 窗口内容,适合“AI 辅导我们自己的学习页面”,比如题库、讲义阅读器、代码练习器、表单填写向导。优点是权限简单,无需额外配置描述文件,只要 App 在前台即可随时截图。缺点是无法捕获其他 App 的内容,不能做跨应用辅导。
第二种方案是 ReplayKit + Broadcast Upload Extension。这是 iOS 官方提供的屏幕共享录制能力。用户在控制中心或者 App 内点击“开始直播/共享屏幕”后,系统会弹出提示,然后系统把屏幕视频帧通过扩展传递给开发者。优点是能捕获整个设备屏幕(或指定 App),适合“AI 辅导用户使用其他软件”。缺点是交互链路复杂、权限提示明显、性能开销大,而且扩展进程与主 App 是独立进程,数据通信需要额外设计。
3.2 方案一:应用内截图代码实现
应用内截图最直接的方式是拿到当前UIWindow,利用drawHierarchy绘制到图形上下文。这里给出一个可以在 SwiftUI 工程中复用的函数:
import UIKit func captureAppScreen() -> UIImage? { // 获取当前活跃的 WindowScene 和 keyWindow guard let windowScene = UIApplication.shared.connectedScenes .compactMap({ $0 as? UIWindowScene }) .first, let keyWindow = windowScene.windows.first(where: { $0.isKeyWindow }) else { return nil } let format = UIGraphicsImageRendererFormat() format.scale = UIScreen.main.scale format.opaque = false let renderer = UIGraphicsImageRenderer( bounds: keyWindow.bounds, format: format ) return renderer.image { _ in keyWindow.drawHierarchy(in: keyWindow.bounds, afterScreenUpdates: true) } }这段代码需要注意三点:
- 必须在主线程调用,否则
drawHierarchy可能绘制出空白内容。 afterScreenUpdates: true表示等屏幕内容更新完成后再绘制,适合捕获最新 UI 状态;但如果调用非常频繁,会有一定性能损耗。- 如果界面包含
SKScene、MTKView、AVPlayerLayer等独立渲染层,drawHierarchy不一定能捕获到内容,需要额外处理或换成UIView快照。
在 SwiftUI 中,可以把它包装成一个Buttonaction 或一个Timer驱动的采集器。截取到的 UIImage 后续会传给视觉结构化模块。
3.3 方案二:ReplayKit + Broadcast Upload Extension
如果你确实需要捕获其他 App 的屏幕,只能选择 ReplayKit 方案。整体流程如下:
首先,在主 App 中为工程新增一个 Broadcast Upload Extension Target。这个扩展本身并不负责展示任何 UI,它只是接收系统传入的屏幕视频帧。Xcode 会自动生成SampleHandler.swift文件,核心方法如下:
import ReplayKit class SampleHandler: RPBroadcastSampleHandler { override func broadcastStarted(withSetupInfo setupInfo: [String: NSObject]?) { // 用户点击开始共享后触发,这里可以通知主 App 开始接收 } override func broadcastPaused() { // 用户暂停共享时触发 } override func broadcastResumed() { // 用户恢复共享时触发 } override func broadcastFinished() { // 用户结束共享时触发 // 记得清理共享容器中的临时文件 } override func processSampleBuffer(_ sampleBuffer: CMSampleBuffer, with type: RPSampleBufferType) { // 系统不断把屏幕/音频/App 音频样本传到这里 // 判断 type == .video 时,从 sampleBuffer 中取出像素缓冲区 guard type == .video else { return } guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } // 将该帧转成 JPEG 或写入共享文件,再通知主 App 读取 // 注意:扩展进程内存受限,不要无限制缓存帧 } }扩展与主 App 是独立进程,不能直接调用主 App 的单例或内存变量。通常需要用 App Group 共享容器传递数据:扩展把视频帧压缩成 JPEG Data,写入UserDefaults(suiteName:)或临时文件,主 App 通过监听通知或轮询读取。考虑到扩展内存很小,建议只保留最近 1~2 帧,或者按需向扩展发送“需要帧”的信号。
使用这套方案时,要明确告知用户系统会显示屏幕共享状态。产品设计上不能把这种录制伪装成无感知后台截屏,这是苹果审核的红线,也是用户隐私的基本要求。
3.4 权限状态检查
无论哪种方案,启动采集前都应该检查权限状态。ReplayKit 方案相对特殊,因为系统没有提供独立的“屏幕录制权限”检查 API,需要在调用相关 API 时捕获错误,并通过RPBroadcastActivityViewController引导用户完成授权。对于应用内截图,则不需要额外的系统权限。
实际开发中更常见的是麦克风权限和相册权限,这里不展开。需要强调的是,无论使用哪种采集方案,应用的《隐私政策》里都应当如实说明屏幕内容的用途、存储方式、上传策略和删除机制,尤其是教育类应用涉及未成年人场景时,必须格外谨慎。
4. 视觉结构化:让 AI 看懂屏幕坐标
4.1 Vision 框架 OCR 与元素检测
拿到 UIImage 之后,下一步是把图片转成“文字 + 位置”的结构化数据。iOS 原生自带 Vision 框架,可以离线完成 OCR,延迟低且不产生网络流量。下面是一个最小可用的 OCR 函数:
import Vision func recognizeText(in image: UIImage) -> [VNRecognizedTextObservation] { guard let cgImage = image.cgImage else { return [] } var observations: [VNRecognizedTextObservation] = [] let request = VNRecognizeTextRequest { request, error in guard error == nil else { return } observations = request.results as? [VNRecognizedTextObservation] ?? [] } request.recognitionLevel = .accurate request.recognitionLanguages = ["zh-Hans", "en-US"] request.usesLanguageCorrection = true let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) try? handler.perform([request]) return observations }说明几个细节:
recognitionLevel = .accurate识别精度高,但速度稍慢;实时预览场景可以改用.fast。recognitionLanguages根据目标用户调整。中文教育场景建议把zh-Hans放在第一位,否则默认模型对中文支持不够稳定。usesLanguageCorrection对英文单词纠错有帮助,但对中文识别有时候会画蛇添足,需要实际测试后决定开关。
遍历结果时,每个VNRecognizedTextObservation包含两部分关键信息:topCandidates(1)能拿到识别文本,boundingBox能拿到归一化坐标。下面代码演示如何提取:
for observation in observations { guard let candidate = observation.topCandidates(1).first else { continue } let text = candidate.string let box = observation.boundingBox print("文本:\(text),归一化坐标:\(box)") }4.2 归一化坐标与屏幕坐标互转
Vision 的boundingBox有一个非常经典的坑:坐标系原点在左下角,而 UIKit/SwiftUI 的原点在左上角。如果不做转换,画出来的高亮框会上下颠倒。
转换公式如下:
// visionBox 是 CGRect,取值范围 0~1 // imageWidth / imageHeight 是原始图片像素尺寸 // uiRect 是 UIKit 左上角坐标系下的矩形 let uiX = visionBox.minX * imageWidth let uiY = (1 - visionBox.minY - visionBox.height) * imageHeight let uiWidth = visionBox.width * imageWidth let uiHeight = visionBox.height * imageHeight let uiRect = CGRect(x: uiX, y: uiY, width: uiWidth, height: uiHeight)这里的核心是先把 Vision 的归一化坐标转换为像素坐标,再做纵向翻转。注意翻转时不仅要翻minY,还要减去矩形自身高度,否则元素会向下偏移一个矩形高度。
当你把坐标发给大模型时,建议统一使用“归一化坐标 + 像素坐标”双份描述。给模型看的 JSON 里带上归一化坐标[0.1, 0.2, 0.3, 0.15],便于模型理解相对位置;渲染时再转成像素坐标,避免因屏幕尺寸不同导致偏移。
4.3 构造 AI 可读的视觉状态描述
大模型并不能直接理解一个矩形框和一段 OCR 文本之间的语义关系。为了让模型“看懂屏幕”,我们需要把 OCR 结果整理成结构化 JSON。建议字段如下:
{ "screen_size": { "width": 1170, "height": 2532 }, "elements": [ { "id": 0, "type": "text", "content": "2x + 3 = 7", "box": [0.08, 0.31, 0.36, 0.08] }, { "id": 1, "type": "input", "content": "", "placeholder": "请输入答案", "box": [0.12, 0.42, 0.28, 0.06] }, { "id": 2, "type": "button", "content": "提交", "box": [0.42, 0.51, 0.16, 0.06] } ] }关于type字段,如果要做得简单,可以先用text统一标注;如果想更精确,可以结合VNDetectRectanglesRequest检测图片中的按钮、卡片、输入框等矩形区域,再通过坐标重叠匹配判断类型。不过这一步在最小可行性版本里可以省略,先把文本元素做好就够了。
生成这段 JSON 的 Swift 代码如下:
struct ScreenState: Codable { let screen_size: CGSizeProxy let elements: [ElementProxy] } struct ElementProxy: Codable { let id: Int let type: String let content: String let box: [CGFloat] } func buildScreenState(from observations: [VNRecognizedTextObservation], imageSize: CGSize) -> ScreenState { var elements: [ElementProxy] = [] for (index, obs) in observations.enumerated() { guard let text = obs.topCandidates(1).first?.string else { continue } let box = obs.boundingBox elements.append(ElementProxy( id: index, type: "text", content: text, box: [box.minX, box.minY, box.width, box.height] )) } return ScreenState( screen_size: CGSizeProxy(width: imageSize.width, height: imageSize.height), elements: elements ) }注意示例中的CGSizeProxy和ElementProxy是为了方便 Codable 序列化而定义的简单结构体,实际项目中可以按你的 JSON 规范调整。
这一阶段做完,你手里的素材已经足够让大模型做“基于坐标的精确回答”了。
5. 接入多模态大模型:把“问题 + 屏幕状态”交给 AI
5.1 提示词设计
接入大模型时,最常见的错误是直接把截图 Base64 塞给模型,然后期待它输出精确坐标。实际效果往往不理想:模型对像素坐标的感知不稳定,很容易出现“指着空白区域说话”的情况。更稳妥的做法是让模型基于结构化 JSON 做推演,再结合局部图片提升理解。
以“数学题目辅导”为例,系统提示词可以这样设计:
你是屏幕辅助学习助手。用户会提供当前屏幕的结构化元素列表, 每个元素包含 id、type、content 和归一化坐标 box。 你的任务是: 1. 根据用户问题,分析屏幕中与问题相关的内容。 2. 如果发现了错误或需要强调的位置,必须引用对应元素的 id。 3. 禁止编造屏幕上不存在的元素。 4. 输出必须是 JSON,字段为: - answer:完整辅导文本 - highlights:需要高亮的元素 id 数组或归一化框数组 - next_actions:建议用户执行的操作列表这段提示词有三个关键设计:
一是“禁止编造元素”。AI 很容易顺着用户的话编造“右上角那个按钮”,即使屏幕上根本没有。明确禁止之后,模型会在找不到匹配元素时如实说“当前屏幕中没有找到相关内容”,而不是忽悠用户。
二是强制 JSON 输出。后续代码解析会方便非常多,也容易做字段校验。
三是把“错误位置”和“下一步操作”分开。既满足用户“哪里错了”的需求,也满足“接下来怎么操作”的指导性需求。
5.2 调用接口与代码示例
以 OpenAI 兼容的 Chat Completions 接口为例,下面给出 Swift 网络请求的核心片段。这个示例目的是演示通用调用方式,具体 URL、模型名、鉴权方式需要按你实际使用的服务调整:
import Foundation struct LLMRequest: Codable { let model: String let messages: [Message] let response_format: ResponseFormat? struct Message: Codable { let role: String let content: String } struct ResponseFormat: Codable { let type: String } } struct LLMResponse: Codable { let choices: [Choice] struct Choice: Codable { let message: Message } } func callLLM(screenStateJSON: String, userQuestion: String, apiKey: String, completion: @escaping (Result<String, Error>) -> Void) { let url = URL(string: "https://api.example.com/v1/chat/completions")! var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") let systemPrompt = """ 你是屏幕辅助学习助手。用户会提供当前屏幕的结构化元素列表,... """ let userContent = """ 当前屏幕状态: \(screenStateJSON) 用户问题: \(userQuestion) """ let payload = LLMRequest( model: "your-model-name", messages: [ LLMRequest.Message(role: "system", content: systemPrompt), LLMRequest.Message(role: "user", content: userContent) ], response_format: LLMRequest.ResponseFormat(type: "json_object") ) request.httpBody = try? JSONEncoder().encode(payload) URLSession.shared.dataTask(with: request) { data, _, error in guard let data = data else { completion(.failure(error ?? NSError(domain: "LLMError", code: -1))) return } do { let response = try JSONDecoder().decode(LLMResponse.self, from: data) let content = response.choices.first?.message.content ?? "" completion(.success(content)) } catch { completion(.failure(error)) } }.resume() }这里有几个需要注意的地方:
response_format字段不是所有模型都支持,支持 JSON Output 的模型才能稳定输出合法 JSON。如果不支持,就需要在提示词里强调“只输出 JSON,不要多余解释”,并在解析时做容错处理。- 生产环境不要把 API Key 写在客户端。正确做法是客户端请求自己的服务端,由服务端保存密钥并转发大模型请求,避免密钥泄露。
- 如果模型允许视觉输入,可以把重要区域的截图裁剪后作为图片一并发送;如果只发送结构化 JSON,则在真实图表、复杂公式场景下理解能力会受限。最优选择是“结构化 JSON + 局部裁剪图”一起送,既控制 token 又提升准确率。
5.3 响应解析与容错
模型输出的 JSON 不一定严谨,常见问题包括:多了一个尾逗号、用单引号代替双引号、在 JSON 前后混入解释性文字。解析时要做好容错。
下面是一个解析示例,使用JSONSerialization先做一次宽松解析,失败后再尝试提取 JSON 片段:
func parseLLMResponse(_ content: String) -> [String: Any]? { // 先直接尝试解析 if let data = content.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { return json } // 失败后尝试提取 {} 之间的内容 if let start = content.firstIndex(of: "{"), let end = content.lastIndex(of: "}"), start < end { let jsonString = String(content[start...end]) if let data = jsonString.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { return json } } return nil }解析完之后,还需要对坐标做合法性校验。模型可能返回负坐标、超过 1 的归一化坐标、或者与屏幕尺寸不匹配的像素坐标。统一处理方式是:只要坐标值不在0...1范围内,就丢弃对应高亮,或者回退到“该元素关联的 OCR 框”。
6. 实现“视觉精确”的结果渲染
6.1 在截图上绘制边界框
把模型返回的高亮元素 ID 映射回ScreenState.elements后,可以拿到归一化坐标。最简单可靠的渲染方式是直接在截图上绘制边界框,然后展示给用户。
SwiftUI 中可以用Canvas完成:
struct HighlightOverlay: View { let image: UIImage let boxes: [CGRect] // 这里放转换后的像素坐标,或者保存归一化坐标动态转换 var body: some View { ZStack { Image(uiImage: image) .resizable() .scaledToFit() Canvas { context, size in for box in boxes { // 需要把像素坐标按当前视图尺寸等比缩放 let scaleX = size.width / image.size.width let scaleY = size.height / image.size.height let rect = CGRect( x: box.minX * scaleX, y: box.minY * scaleY, width: box.width * scaleX, height: box.height * scaleY ) let path = Path(roundedRect: rect, cornerRadius: 12) context.stroke(path, with: .color(.orange), lineWidth: 4) context.fill(path, with: .color(.orange.opacity(0.15))) } } .allowsHitTesting(false) } } }建议用scaledToFit配合动态等比缩放,而不是直接写死尺寸,这样在不同 iPhone 上都能显示正常。allowsHitTesting(false)保证覆盖层不阻挡用户的点击操作。
6.2 在实时覆盖层中做高亮标注
如果产品形态是“实时辅导”,比分说用户一边操作屏幕一边接收指导,那么可以创建一个独立的透明 UIWindow 覆盖在内容层之上。这个 window 的windowLevel设置高于普通内容,但不遮挡系统状态栏:
let overlayWindow = UIWindow(windowScene: windowScene) overlayWindow.windowLevel = .alert + 1 overlayWindow.backgroundColor = .clear overlayWindow.rootViewController = UIHostingController( rootView: HighlightOverlay(image: currentFrame, boxes: boxes) ) overlayWindow.isHidden = false使用覆盖层时要注意两点:
一是覆盖层不该完全拦截触摸事件。如果需要在高亮区域显示可点击的按钮,比如“点击此处查看详细解析”,那么只让按钮区域响应触摸,其他区域设置allowsHitTesting(false)。
二是根据业务场景,不要一直占满全屏。长时间遮挡屏幕会影响用户操作。合理的交互是:AI 给出高亮后,用户点击“完成”即自动消失;或者高亮只保留 3~5 秒。
6.3 点击坐标回传与交互闭环
“视觉精确”最有价值的地方,是可以把 AI 的建议变成可点击的入口。例如模型说“请点击右上角的提交按钮”,客户端如果能将归一化坐标映射到按钮区域,就可以在覆盖层上画一个“点击”按钮,用户点击后触发回调,App 内部执行相应跳转或操作。
但这里有一个 iOS 平台边界:如果你的 App 要模拟点击另一个 App 的界面,iOS 官方没有为普通 App 开放任意模拟触摸的公共 API。所以一个更现实的方案是:在自己的 App 内部,根据坐标执行内部跳转;在辅导其他 App 的场景,只做“高亮引导 + 用户手动点击”,避免触碰系统限制。
换句话说,视觉精确的最终落点不一定是“替你操作”,而是“精确告诉你操作哪里”。从产品角度来看,这种交互反而更容易获得用户信任。
7. 常见问题与排查思路
开发过程中,最大的时间消耗往往来自权限、坐标系和扩展进程通信。下面整理一张高频问题表,并逐一展开说明。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| drawHierarchy 截图为空白 | 非主线程调用、图片尺寸为 0 | 确保主线程执行,检查 window.bounds |
| Vision OCR 识别中文不准确 | 未设置中文识别语言 | 设置recognitionLanguages = ["zh-Hans", "en-US"] |
| 高亮框上下颠倒或偏移 | Vision 坐标系与 UIKit 不一致 | 按公式翻转 Y 轴,并减去高度 |
| 模型返回坐标越界 | 模型幻觉或归一化理解错误 | 解析后校验 0...1 范围,非法值丢弃 |
| Broadcast Extension 无法启动 | 签名配置或 App Group 配置错误 | 检查 entitlement,确保主 App 与扩展共享 Group |
| 扩展与主 App 数据不同步 | 进程间通信时序问题 | 使用 Darwin Notification + App Group 文件传递 |
| 覆盖层无法显示 | windowLevel 设置过低 | 使用.alert + 1层级 |
下面挑几个最常见的展开讲。
7.1 Broadcast Extension 无法拉起
表现:在主 App 中跳转RPBroadcastActivityViewController后,用户选择“开始直播”,但SampleHandler.broadcastStarted一直没有被调用。
排查步骤建议按顺序执行:
- 确认 Broadcast Upload Extension 的 Bundle Identifier 是否正确,并且 Extension 所属 Target 与主 App 在同一个 App Group 中。
- 检查 Extension 的 Deployment Target,确保不低于主 App 的最低版本。
- 在 Extension 的
broadcastFinished里加日志,确认是否有被动结束。 - 用真机测试。模拟器对 ReplayKit 支持有限,很多场景跑不通。
常见根因是 Extension 没有正确签名,或者主 App 的NSExtension配置缺少RPBroadcastProcessMode字段。
7.2 高亮框位置总是偏上或偏下
表现:模型返回的坐标看起来对,但画出来的高亮框老是对不准文字。
首先检查 Vision 坐标转换是否遗漏了高度。很多初学者只做了一次翻转y = 1 - minY,忘了减去矩形高度。正确的转换公式是:
let y = (1 - visionBox.minY - visionBox.height) * imageHeight其次检查图片裁剪逻辑。如果截图时取了屏幕一部分区域,但 OCR 用的是整张图片,坐标比例就会被拉伸。规范做法是:截图、OCR、渲染三者的参考图片尺寸保持一致。
7.3 模型总在“编造屏幕元素”
表现:屏幕上明明没有“重置按钮”,模型却一本正经地分析按钮位置。
这类问题本质上是提示词对模型的约束不够。解决办法可以从三个方向同时入手:
- 在系统提示词中明确添加“只能引用给定 elements 中存在的 id,禁止描述不存在的元素”。
- 在用户消息中追加一句判断规则:“如果没有找到相关元素,请输出空的高亮数组,并解释原因。”
- 在后端增加校验服务,当模型返回的元素 id 不在
ScreenState.elements中时,自动丢弃该高亮并追加一条修复请求。
7.4 屏幕录制内存持续增长
ReplayKit 扩展在较老的机型上容易出现内存吃紧,因为系统不断把视频帧传给扩展。解决思路是:不要保存所有帧,只保留最新一帧,或者设定一个时间间隔,例如每 2 秒采集一帧,或只在用户点击“暂停”时采集。另外,帧转 JPEG 时注意使用UIImage的压缩参数控制体积:
if let data = image.jpegData(compressionQuality: 0.6) { // 写入共享容器 }一般 0.5~0.7 的压缩质量已经足够 OCR 使用,没必要用 1.0 无损压缩。
8. 最佳实践与工程建议
8.1 隐私合规是一条硬边界
做这类 AI 教育应用,隐私不是可选项,而是第一优先级。屏幕截图可能包含账号信息、个人信息、聊天记录,甚至未成年人面部信息。工程上我建议至少做到以下几点:
- 默认不上传原始截图。优先在端侧完成 OCR 和元素检测,只把文本、坐标、屏幕尺寸发给模型。
- 必须上传截图时,对图片做脱敏处理。比如先裁剪问题区域,再使用系统隐私遮罩或手动打码。
- 提供“单次授权”机制。用户每次发起辅导时再触发采集,而不是进入 App 就自动采集。
- 服务端保存的日志不要包含完整截图,只保留结构化 JSON 和模型结果,并给用户提供一键清除学习记录的能力。
8.2 降低大模型成本与延迟
视觉结构化之后,发给模型的文本已经比原始截图小很多,但多轮对话中历史上下文仍会越来越大。建议采用以下策略:
- 只保留最近 3~5 轮对话摘要,不保存完整历史。
- 每次发送屏幕状态时,只发送用户问题关联区域附近的元素。例如问题提到“公式”,就只保留 OCR 文本框中包含数学符号的元素,过滤掉状态栏、底部 Tab 栏等无关内容。
- 图片传给模型前先裁剪,按元素框外扩一定像素,而不是发整张截图。
8.3 不要让模型直接暴露给客户端
真实项目中,客户端不应该直接持有大模型 API Key。正确架构是:iOS 客户端 → 自己的后端服务 → 大模型服务。
后端可以承担几项关键职责:
- 统一封装不同模型提供商的接口,切换模型时客户端无需改动。
- 对模型输入做脱敏和内容安全检测。
- 对模型输出做 JSON Schema 校验,拦截非法坐标。
- 记录每次辅导的输入输出,用于评估模型质量和后续微调数据集建设。
8.4 模型输出的坐标必须做“合法范围校验”
模型输出的highlights数组,可能在理论上完全合法但在现实中指向空白区域。建议在服务端增加一层校验函数:
func validateHighlight(_ box: [CGFloat], elements: [ElementProxy]) -> Bool { guard box.count == 4 else { return false } for value in box { guard value >= 0, value <= 1 else { return false } } // 可选:检查是否与任一 OCR 元素框重叠 let highlightRect = CGRect(x: box[0], y: box[1], width: box[2], height: box[3]) for element in elements { let elementRect = CGRect(x: element.box[0], y: element.box[1], width: element.box[2], height: element.box[3]) if highlightRect.intersects(elementRect) { return true } } return false }如果没有任何重叠,就直接把该高亮判断为无效,避免用户看到 AI 指向空白区域。
8.5 建立离线回归数据集
视觉精确类功能最怕“调一次坏一次”。改了一版提示词,可能某类题目更准了,但另一类屏幕的误报率上升了。建议从第一天起就建立离线回归数据集:
- 收集真实用户授权的屏幕截图和问题记录。
- 每一条样本标注:正确高亮元素 id、正确回答文本、正确操作步骤。
- 每次修改提示词或模型后,先跑一遍离线数据集,对比高亮命中和文本准确率,再发布到线上。
这一步听起来重,但对教育类产品非常值得。没有回归数据集的 AI 功能,后期维护会非常痛苦。
9. 总结与下一步学习路线
回到最开始提到的 Show HN 项目:Visually Precise AI Tutoring on iOS。这类产品的技术骨架,本质上就是本文这条链路:屏幕采集、视觉结构化、大模型推理、坐标渲染。它不是单一技术点,而是多个系统能力的组合。真正决定体验上限的,不是某一个模型有多强,而是你能不能把“屏幕状态”准确转成模型可消费、又能映射回屏幕坐标的结构化数据。
如果你想从零开始尝试,我的建议是先放弃实时屏幕录制,做一个最小闭环:在你自己 App 内截屏 → Vision OCR → 生成 JSON → 调用大模型 → 在截图上画框。这条链路两天左右就能跑通,能让你快速感受到“视觉精确反馈”和“纯文本回答”的差异。跑通之后,再逐步加入 Broadcast Extension、多轮对话、局部图片传输和线上回归评测,每一步都有明确的可验证标准。
过程中遇到问题,优先从两个角度排查:一是权限链路有没有完整走通,二是坐标系到底有没有翻转正确。这两类问题占据了我个人在同类项目中超过一半的调试时间。如果你正在 iOS 上做 AI 辅导或智能操作引导,欢迎把本文收藏起来,等真正动手时对照着配置和排错,能少走不少弯路。