最近遇到一个很有意思的开源项目:Kuma Voice。它的定位很简单,但足够戳中很多 Apple Watch 用户的痛点——一个开源的 Apple Watch 语音助手,不需要 iPhone。这里说的"不需要 iPhone",不是说手表能脱离手机完成一次性的初始配对,而是说在手表日常运行的过程中,不再需要手机作为计算节点、中转站或者数据来源。如果你动手做过 watchOS 开发,就会明白这个差异到底意味着什么。
这篇文章不准备只贴项目介绍。我想以 Kuma Voice 为切入点,把"独立 watchOS 语音助手"这条技术链路完整拆一遍:从 Watch-only App 的工程架构,到语音识别权限、麦克风采集、意图解析、TTS 回复,再到真机调试和常见坑。即使你完全不打算用这个项目,看完也能对齐一套关于 watchOS 独立应用的实现思路。
先给一个明确判断:Kuma Voice 这类项目真正的价值,不在"多了一个语音助手",而在它证明了第三方开发者可以在不借助 iPhone 的情况下,用系统框架在手表上跑通一套语音交互链路。这是 watchOS 平台开放能力的一次实践展示。
1. 这篇文章真正要解决的问题
很多人对 Apple Watch 语音助手的认知,停留在"Siri 必须靠 iPhone"这个阶段。实际情况是,从 watchOS 版本迭代开始,Apple Watch 已经支持独立的应用运行和网络连接。只要手表具备 Wi-Fi 或蜂窝网络能力,应用完全可以在没有 iPhone 在场的情况下访问网络、采集音频、调用系统能力。
但事情没这么简单。真正做过 watchOS 独立应用的人都知道,这套链路里有几个很现实的问题:
第一,权限模型比 iOS 更敏感。语音识别和麦克风权限需要明确的用途声明,没有配置 Info.plist,应用会在启动时静默失败,而不是弹出权限框。第二,音频会话管理在手表上更脆弱。watchOS 的资源限制比 iOS 严格,长时间挂着 AVAudioEngine 会导致系统杀掉进程。第三,后台执行能力极其有限。iOS 可以靠 Background Task 做很多事情,watchOS 上想后台定时提醒,需要另一套 API。
如果你在网上搜索 Kuma Voice、Apple Watch、voice assistant 这些关键词,会看到大量关于"手表助手是否真的脱离手机"的讨论。但这些讨论大多停留在概念层面。这篇文章想解决的,是把这套概念落到工程层面:你到底需要哪些权限?代码应该怎么写?验证要怎么做?失败了怎么排?
读完你至少能获得四样东西:
- 理解"不需要 iPhone"在 watchOS 工程上的准确含义;
- 掌握 Speech framework 在 watchOS 上的最小可用写法;
- 知道一个语音助手的意图解析和回复链路怎么组织;
- 拿到一份可直接套用的常见问题排查清单。
2. "不需要 iPhone"到底意味着什么:Watch-only 应用的技术基础
要真正理解 Kuma Voice,先要重新理解 watchOS 应用的发展历史。
早期的 watchOS 应用并不是真正独立的应用。第三方应用被打包成一个 extension,挂在配套 iOS 应用内部,手表只是 iOS 应用的一个展示层。这意味着你打开手表应用,实际上是在间接使用手机的网络、数据和计算能力。这就是"不依赖 iPhone"和"依赖 iPhone"这个问题的根源——旧架构下,手表根本不是一个独立计算设备。
转折点出现在 watchOS 6。从那个版本起,Apple 正式允许开发者创建Watch-only App,也就是不再需要 iOS companion app 的独立手表应用。这个变化是 Kuma Voice 这类项目存在的制度前提。
但必须澄清一个容易误解的点:Apple Watch 的初始激活和配对,目前仍然需要一部 iPhone。手表第一次开机、写入账号、同步系统设置,这些环节逃不掉 iPhone。Kuma Voice 标题里的"no iPhone needed",说的是运行阶段,不是激活阶段。
具体到工程上,"运行阶段不需要 iPhone"意味着三件事:
- 网络独立:手表通过 Wi-Fi 或蜂窝网络直接发起 URLSession 请求,不需要经过 iPhone 转发;
- 数据独立:应用的设置、缓存、历史记录都存储在手表本地,不走 iPhone 同步也不依赖 WCSession;
- 计算独立:语音识别、意图判断、回复生成,要么在手表端完成,要么由手表直接请求云端服务完成。
还有一个用户容易忽略的技术细节:Apple Watch 必须和 iPhone 保持配对关系,但配对之后,只要手表能连上网络,它就可以独立运行大多数功能。蜂窝版手表在这方面体验最好,Wi-Fi 版在离开手机但是连着已知 Wi-Fi 时也能工作,只是需要网络环境配合。
所以,Kuma Voice 的"不需要 iPhone",本质上是一个架构选择:项目按 Watch-only App 的方式设计和部署,所有能力都以手表为边界展开。这也直接影响了它的代码组织方式。如果某个环节还需要借助 iPhone 的计算能力,那标题就不会这么写了。
3. Kuma Voice 的核心模块与技术选型
从项目标题和同类开源助手的一般结构来看,一个不依赖 iPhone 的 watchOS 语音助手,至少需要四层能力。我把它们拆开,顺便给出每层可选的系统 API。
| 模块 | 职责 | watchOS 上可用的能力 | 常见技术选型 |
|---|---|---|---|
| 音频采集 | 从手表麦克风采集用户语音 | AVAudioEngine、AVAudioSession | Speech framework 内置的录音 pipeline |
| 语音识别 | 将音频转成文字 | Speech framework 的 SFSpeechRecognizer | 系统识别服务,本地或远端 |
| 意图解析 | 从文字中拿出用户想做的事 | 自研规则 / 本地模型 / 云端 LLM | 小型关键词规则,或后端 LLM API |
| 动作执行 | 执行具体操作 | Timer、WKInterfaceDevice、网络请求 | 系统 API + URLSession |
| 语音回复 | 把结果读给用户 | AVSpeechSynthesizer | 系统 TTS,中文用 zh-CN 语音 |
这套链路和我们在手机上做的语音助手没有本质区别,唯一的差异在于硬件约束:手表屏幕小、交互入口少、CPU 和内存更紧张、电池有限。所以 Kuma Voice 这类项目在技术选型上通常会倾向于两个原则:
第一个原则是能少做就少做。语音识别尽量交给系统 Speech framework,而不是嵌套一个几百 MB 的离线识别引擎。watchOS 应用的包体积限制很严格,嵌入大模型引擎不现实。
第二个原则是云端分担理解压力。如果项目接了云端大模型做意图解析,那么手表端通常只负责录音、识别和播放回复,语义理解放在服务端。这样手表端代码量会小很多。
从开源项目的性质来看,Kuma Voice 还有一个隐性优点:可审计。用户能直接看源码来确认音频是否被上传、上传到哪里、本地保存了什么数据。这个优势在语音类应用里非常关键,因为语音天然是敏感信息。如果你打算二次开发,也可以从源码层面评估它的隐私边界,而不是只听官方说明。
4. 环境准备与前置条件
在开始写代码之前,先说清楚环境。watchOS 开发的环境准备比 iOS 要复杂一点,因为很多功能在模拟器上不可用或表现很差。
- 基础环境:macOS + Xcode。Xcode 本身自带 watchOS SDK,不需要额外安装工具链。
- 代码语言:Swift + SwiftUI。SwiftUI 是目前官方主推的 watchOS 界面开发方式,WatchKit 的纯代码方式已经逐渐边缘化。
- 系统版本:本文给出的代码基于较新的 Swift 语法,如果你的 Xcode 版本偏旧,可能需要把
if let简写改回传统写法。具体版本请以你的实际项目为准。 - 真机调试:强烈建议准备一台 Apple Watch 真机。watchOS 模拟器能跑界面,但麦克风、语音识别、音频会话这类依赖硬件的功能,模拟器表现不可靠。
- 签名要求:watchOS 真机部署需要 Xcode 签名配置。免费个人账户在某些情况下受到限制,如果签名一直失败,更稳妥的做法是使用 Apple Developer Program 账号。
工程创建的方式也值得说清楚。在 Xcode 中新建项目时,选择 watchOS 下的 App 模板。重点在于目标配置:如果你只需要独立的手表应用,不需要 iPhone 上的 companion app,就要把项目配置成Watch-only App。在 Xcode 的目标配置中,只保留 Watch App target,移除或不要创建 iOS App 作为宿主 target。
这里真正容易踩坑的地方是:很多开发者沿用旧习惯,先创建一个 iOS 项目,再往里面塞 WatchKit App。这样做不是不行,但它天然适合"手表应用依赖手机应用"的架构。Kuma Voice 这类"完全脱离 iPhone"的项目,最好从创建 watchOS App 模板开始,避免后续剥离宿主关系时的麻烦。
另外,如果你是想把源码拉下来跑跑看,建议先看 README 里写的 watchOS 最低版本要求。语音识别和独立网络请求在不同 watchOS 版本上的行为有差异,版本太老会导致 API 不可用。
5. 最小闭环:在 watchOS 上实现语音识别
现在进入核心代码部分。我们先用一个最小闭环验证:按下按钮、开始录音、 Speech framework 把语音转成文字。这是整个语音助手的底座。
5.1 配置权限声明
与 iOS 一样,在 watchOS 上使用麦克风和语音识别,必须先在 Info.plist 中声明用途。缺少声明不会崩溃,但会在请求权限时直接失败,并伴随一段不明显的日志。
<!-- 文件路径:watchOS App target 的 Info.plist --> <key>NSMicrophoneUsageDescription</key> <string>Kuma Voice 需要访问麦克风来接收你的语音指令</string> <key>NSSpeechRecognitionUsageDescription</key> <string>Kuma Voice 需要使用语音识别能力来理解你说了什么</string>需要注意,这两个权限文案不是摆设。一方面 App Store 审核会检查用途描述是否与实际功能一致;另一方面,用户在手表上看到权限弹窗时,文案如果含糊,授权率会明显下降。
5.2 请求语音识别权限
Speech framework 的权限请求是独立的。建议在应用启动后尽早请求,而不是在用户已经按下录音按钮时才弹窗。手表上的交互成本比手机高,临时弹窗容易让用户一头雾水。
// 文件路径:SpeechAuth.swift import Speech enum SpeechAuth { static func requestPermission() async -> Bool { let status = await withCheckedContinuation { continuation in SFSpeechRecognizer.requestAuthorization { state in continuation.resume(returning: state) } } switch status { case .authorized: return true case .denied, .restricted, .notDetermined: return false @unknown default: return false } } }5.3 核心识别类
这个类是关键。它做的事情是:创建音频引擎、从麦克风输入节点持续采集音频、把音频数据交给 SFSpeechAudioBufferRecognitionRequest,最后从识别结果中取出文字。
// 文件路径:SpeechRecognizer.swift import Speech import AVFoundation final class SpeechRecognizer: ObservableObject { @Published var transcript = "" @Published var isRunning = false private let audioEngine = AVAudioEngine() private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? private var recognitionTask: SFSpeechRecognitionTask? private let recognizer = SFSpeechRecognizer(locale: Locale(identifier: "zh-CN")) func start() throws { // 启动音频会话 let audioSession = AVAudioSession.sharedInstance() try audioSession.setCategory(.record, mode: .measurement, options: .duckOthers) try audioSession.setActive(true, options: .notifyOthersOnDeactivation) // 创建识别请求 let request = SFSpeechAudioBufferRecognitionRequest() request.shouldReportPartialResults = true recognitionRequest = request guard let recognizer, recognizer.isAvailable else { throw SpeechError.recognizerUnavailable } // 从麦克风采集音频并送入请求 let inputNode = audioEngine.inputNode let recordingFormat = inputNode.outputFormat(forBus: 0) inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { buffer, _ in request.append(buffer) } audioEngine.prepare() try audioEngine.start() isRunning = true // 识别结果回调 recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in guard let self else { return } if let result { self.transcript = result.bestTranscription.formattedString } if error != nil || result?.isFinal == true { self.stop() } } } func stop() { audioEngine.stop() audioEngine.inputNode.removeTap(onBus: 0) recognitionRequest?.endAudio() recognitionTask?.cancel() isRunning = false } } enum SpeechError: Error { case recognizerUnavailable }这段代码有几点值得说明。
第一,SFSpeechRecognizer(locale:)如果当前系统不支持指定区域,会返回 nil。示例里用了zh-CN,如果用户手表是英文系统,这里可能出现识别器不可用。更健壮的做法是回退到Locale.current。
第二,installTap是持续采集的机制。每次麦克风收到音频数据,都会通过 buffer 追加给识别请求。这个 tap 在stop()里必须移除,否则再次启动时会崩溃或重复叠加采集。
第三,isAvailable表示识别器当前是否可用。网络有问题、识别服务不可用、或者权限没有授权时,这里都会变成 false。
5.4 一个简单的触发界面
在手表上,最自然的交互是点击一个按钮开始说话,再点击一次结束。SwiftUI 代码如下:
// 文件路径:ContentView.swift import SwiftUI struct ContentView: View { @StateObject private var recognizer = SpeechRecognizer() var body: some View { VStack(spacing: 16) { Text(recognizer.transcript.isEmpty ? "点击开始说话" : recognizer.transcript) .multilineTextAlignment(.center) .foregroundStyle(.secondary) Button(recognizer.isRunning ? "停止" : "开始") { if recognizer.isRunning { recognizer.stop() } else { try? recognizer.start() } } .buttonStyle(.borderedProminent) } .padding() } }到这里,一个最小的语音识别闭环就完成了。你把手表举到嘴边,点击开始,说出"设置一个 3 分钟计时器",界面上应该会出现对应的文字。
6. 从语音到指令:意图解析与语音回复
识别出文字只是第一步。语音助手之所以是"助手",是因为它能从文字里提取意图,然后执行动作,最后用语音回复用户。这一节处理的是"从文本到行动"这一段链路。
6.1 简易意图解析
watchOS 应用不可能像完整后端那样跑一个庞大的 NLP 服务。简单场景下,用关键词规则就能覆盖不少需求,这也是很多开源 watchOS 助手常用的起步方案。
// 文件路径:CommandParser.swift import Foundation enum Command { case startTimer(seconds: Int) case unknown } struct CommandParser { static func parse(_ text: String) -> Command { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.contains("计时") || trimmed.contains("timer") { let digits = trimmed.filter { $0.isNumber } if let seconds = Int(digits), seconds > 0 { return .startTimer(seconds: seconds) } return .startTimer(seconds: 60) } return .unknown } }这个解析器的实现很初级,但思路是对的:先判断语义关键词,再提取参数。真正做复杂一点,可以引入正则、同义词表、或者把整个文本交给云端大模型去分类。规则解析的好处是快速、离线可用、行为可预期;坏处是覆盖面窄,用户换个说法就失效。
6.2 语音回复
执行完指令之后,需要把结果念给用户听。watchOS 上可以直接使用 AVSpeechSynthesizer。
// 文件路径:VoiceReplier.swift import AVFoundation final class VoiceReplier { private let synthesizer = AVSpeechSynthesizer() func speak(_ text: String) { let utterance = AVSpeechUtterance(string: text) utterance.voice = AVSpeechSynthesisVoice(language: "zh-CN") utterance.rate = 0.5 synthesizer.speak(utterance) } }注意,AVSpeechSynthesizer 会接管音频输出。如果你的 App 还在同时播放其他音频,需要提前规划好音频会话参数。另外,用户可能在嘈杂环境下使用,语速不要设置太快,0.5 左右是一个偏向清晰的起步值。
6.3 把两部分串起来
在 SwiftUI 的按钮回调里,把"结束录音、解析文本、执行动作、语音回复"串成完整链路:
func handleCommand(_ text: String) { switch CommandParser.parse(text) { case .startTimer(let seconds): replier.speak("好的,\(seconds) 秒后提醒你") // 这里启动 WKExtendedRuntimeSession 以支持后台计时 case .unknown: replier.speak("没听清,请再说一次") } }这里有一个重要的 watchOS 工程坑:普通 Timer 在 watchOS 上不会可靠地在后台运行。如果你的应用退到后台,系统很快会挂起进程。要做一个真正能够到点提醒的计时器,需要用到WKExtendedRuntimeSession,它专门用于延长手表应用的后台运行时间。初次接触 watchOS 的开发者,最容易在这个地方误以为"写个 Timer 就完事了"。
6.4 如果要接云端大模型
很多智能助手最终会接入大模型做语义理解。这里有一个安全建议:不要把 API Key 放到 watchOS 客户端里。手表应用没有足够强的安全边界,Key 一旦泄漏,随之而来的就是费用失控和安全问题。
更稳妥的架构是:手表端只负责录音和语音回复,所有理解请求都发送到你自己的后端,由后端持有 Key、做鉴权、做限流,再调用大模型。请求示例大致如下:
// 文件路径:AssistantService.swift import Foundation struct ChatRequest: Codable { let messages: [ChatMessage] } struct ChatMessage: Codable { let role: String let content: String } func askAssistant(_ text: String) async throws -> String { var request = URLRequest(url: URL(string: "https://api.example.com/assistant")!) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONEncoder().encode( ChatRequest(messages: [ChatMessage(role: "user", content: text)]) ) let (data, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { throw URLError(.badServerResponse) } return String(data: data, encoding: .utf8) ?? "" }把请求地址放到客户端,本身是可接受的,但服务端一定要自己做鉴权,比如校验客户端签名、用户 Token,而不是暴露可直接调用付费模型的裸接口。
7. 运行验证与效果判断
代码写完,接下来是验证环节。watchOS 应用的真机调试步骤比 iOS 多一些,这里整理一套标准的验证流程。
7.1 部署到手表
在 Xcode 中选中你的 Watch App scheme,把 Deployment Target 设置为连接的手表设备,直接 Run。第一次运行时,系统会提示安装到配对的手表上。
需要特别注意:手表应用是通过配对的 iPhone 安装到手表上的,但安装完成后的运行不依赖 iPhone。如果 Xcode 提示找不到设备,先检查 iPhone 与手表的配对状态,再检查 watchOS 与 Xcode 版本是否兼容。
7.2 授权链路验证
首次启动后,应用应该依次弹出麦克风权限和语音识别权限。如果只有一个弹窗,或者干脆不弹,优先检查 Info.plist 是否真的写入了当前 target 的配置。
7.3 识别链路验证
点击"开始"按钮,对着手表说一句简短的话。预期现象是:屏幕上的文字会实时更新。这里分成两个观察点:
- 实时文字出现:说明音频采集和 speech 识别通路正常;
- 停止后能正确解析:说明意图解析链路正常,会给出对应的语音回复。
如果文字没有出现,但按钮状态正常,优先检查麦克风是否被系统静音、手表是否处于"静音模式"或"剧院模式",以及 AVSpeechSynthesizer 的音频输出是否被静音。
7.4 判断成功的标准
一个完整的验证用例应该覆盖"唤醒、说话、识别、理解、回复"五个环节。最简单的方式是打印日志,在每一层都输出关键信息:
- 音频会话是否启动成功;
- 识别器是否可用;
- 最终识别文本是什么;
- 意图解析结果是什么;
- 语音回复是否触发。
如果每一层都有日志,定位问题就非常方便。watchOS 上可以通过 Xcode 的 console 直接查看输出,不需要额外工具。
8. 常见问题与排查思路
watchOS 上的语音助手开发,很多问题不是出在业务逻辑,而是出在权限、音频会话和系统限制上。下面是实际开发中最容易遇到的几类问题。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 权限弹窗没有出现 | Info.plist 缺少用途声明 | 检查 target 的 Info.plist 配置 | 添加NSSpeechRecognitionUsageDescription和NSMicrophoneUsageDescription |
| 语音识别结果一直为空 | 识别器不可用或语言环境不匹配 | 检查recognizer.isAvailable和 locale 配置 | 回退到Locale.current,联网后重试 |
| 点击开始后音频引擎崩溃 | 音频会话配置不对,或上一次 tap 未移除 | 查看崩溃日志,检查removeTap是否调用 | 在stop()中移除 tap,确保可以重复启动 |
| 应用退到后台后提醒失效 | watchOS 后台执行限制 | 检查计时器是否在真机上退出后台后失效 | 改用WKExtendedRuntimeSession延长后台运行 |
| 语音回复没有声音 | 手表静音模式启动,或音频输出冲突 | 检查手表是否静音、AVAudioSession 是否 active | 手动调整音频会话,或提示用户取消静音 |
| 识别速度明显偏慢 | 网络状态不佳,识别服务依赖网络 | 检查手表 Wi-Fi 或蜂窝信号 | 优化音频采样格式,或考虑离线识别方案 |
| 无法连接后端 API | 手表未连网,或 ATS 限制 | 检查手表网络与请求错误 | 配置 HTTPS 请求,确保后端支持 TLS |
有一个问题值得单独强调:watchOS 模拟器上的语音识别体验和真机差异很大。模拟器可以用电脑的麦克风,但音频会话的表现、权限弹窗的触发方式、识别结果的实时性,都和真机不完全一致。如果你的目标是做一个真正能戴出门的助手,一定要尽早换到真机调试。
9. 最佳实践与工程建议
如果你的目标是像 Kuma Voice 一样做一个开源 watchOS 语音助手,或者你想在现有项目里加入语音能力,下面这些工程建议值得参考。
9.1 音频会话策略要简单稳定
在 watchOS 上,不要像 iOS 那样把音频会话玩出太多花样。watchOS 硬件资源有限,复杂的setCategory参数和频繁的 session 切换,很容易引发奇怪的问题。推荐的做法是:开始识别时统一设为.record模式,识别结束时恢复默认状态,必要时才使用.duckOthers去压低其他音频。
9.2 把权限请求前置,把失败兜底做全
语音助手的核心链路依赖权限。建议在第一次启动时就引导用户授权,而不是等到用户点"开始说话"才弹窗。同时,权限被拒绝后要给出明确的 UI 提示,不要让用户点击按钮后毫无反应。
9.3 离线兜底和网络降级
手表端的语音识别可能依赖网络。如果用户的手表没有 Wi-Fi 或蜂窝网络,识别链路会直接失败。工程上要设计降级策略:比如本地先缓存用户指令文本,等网络恢复后补做意图解析;或者至少给出"当前无法识别,请稍后再试"的语音反馈。
9.4 明白 watchOS 的后台边界
这是 watchOS 开发者和 iOS 开发者最大的认知差异。在 iOS 上,你有很多合法方式延长后台执行时间;但在 watchOS 上,WKExtendedRuntimeSession是处理后台任务的主要手段,而且它也有时间上限。涉及计时器、健身体训、导航这类需要后台运行的功能时,一定要在设计阶段就考虑这个限制,否则 App 退到后面就被挂起。
9.5 开源合规检查不能少
Kuma Voice 是 OSS 项目,这是它的优点,但"使用 OSS"和"合规使用 OSS"是两件事。如果你要在自己的项目里集成它,或者给它贡献代码,需要注意依赖库的许可证兼容性。
常见的做法包括:
- 引入依赖前做许可证扫描,确认是 MIT、Apache 2.0 等宽松许可,还是 GPL 等具有传染性的许可;
- 建立 SBOM(软件物料清单),记录每个第三方组件的版本和许可证;
- 保留原有的 LICENSE、NOTICE、COPYRIGHT 声明;
- 如果项目会对外分发,最好使用扫描工具定期检查整个依赖树,避免无意中引入存在合规风险的组件。
10. 总结与后续学习方向
Kuma Voice 这个项目的出现,说明了一件事:Apple Watch 作为独立设备的边界,正在被开发者一点点推开。过去我们默认手表上的助手离不开 iPhone,现在开源社区已经在用实际代码证明,语音识别、意图解析、语音回复这套完整链路,可以在手表上独立跑通。
这篇文章带你把这条链路完整过了一遍:从 Watch-only App 的架构基础,到权限声明、音频采集、识别、意图解析、语音回复,再到真机验证和常见问题排查。你手里的最小示例,已经是一个能"听、懂、说"的雏形。
接下来如果你想继续深入,可以沿着这几个方向走:
- 把意图解析从规则升级为云端 LLM,并设计一套后端鉴权方案;
- 研究
WKExtendedRuntimeSession,把计时器、提醒这类后台任务做扎实; - 尝试接入 HomeKit,让手表能直接控制智能家居;
- 体验 Kuma Voice 的源码,看看社区项目在架构拆分、错误处理、权限设计上的取舍。
如果你正准备做自己的 watchOS 助手,建议先把文中的最小闭环跑通,再逐步加功能。手表端的调试成本比手机高,越早暴露问题,后面的改进越轻松。