最近在做一个语音记录类的App,需要把用户的语音实时转成文字,第一反应是接入市面上的云端语音识别SDK,但后来权衡了一下,决定直接使用iOS系统自带的语音识别能力,也就是Speech框架配合Swift来搞定。整个过程走下来,发现原生的方案比想象中靠谱,但坑也比文档里写的多不少。这篇就把从工程配置到识别落地的完整流程整理出来,踩过的坑、调优的心得都会讲到,打算在iOS端做语音转文字的朋友可以参考省点时间。
先说下这套方案能干什么:实时听写、语音转文字、语音指令识别,都能做。整体思路是先用AVAudioEngine采集麦克风音频流,再把它送入SFSpeechRecognizer创建识别任务,通过回调不断拿到转写结果。它最大的优势是免费、无需额外账号、不依赖第三方网络服务,苹果对开发者开放了完整的API,用户也不需要装额外的东西。
适合读这篇的人有两类:一类是你从来没接触过iOS语音识别,想看看原生方案到底行不行、怎么用;另一类是你已经试过集成第三方语音SDK,被价格、稳定性、隐私合规折磨过,想换一个更轻量方案。不管哪种,下面这套流程都能直接落地。
1. 为什么选择iOS原生语音识别方案
1.1 原生Speech框架能做什么
Speech框架(Speech.framework)是苹果从iOS 10开始提供的语音识别能力,核心类就几个:SFSpeechRecognizer负责创建和管理识别任务,SFSpeechAudioBufferRecognitionRequest用于处理实时音频流,SFSpeechURLRecognitionRequest用于识别本地音频文件,最终通过SFSpeechRecognitionTask拿结果。听起来很复杂,但实际上它们的分工非常清晰。
在处理实时录音时,整体链路是这样的:AVAudioEngine采集麦克风的音频Buffer,然后不断把Buffer塞给SFSpeechAudioBufferRecognitionRequest,识别器边接收边分析,通过resultHandler回调把已经识别出来的文本片段吐出来。使用方拿到文本后,可以自己决定是拼接展示、做指令匹配还是直接保存。
这套方案有几个很实际的优点:识别过程完全在系统层面完成,开发者不需要关心音频编码、传输协议这些细节;而且它本身是离线可用和联网增强混合的机制,在某些情况下即使设备断网也能做基础识别。对于我这种只是需要在自家App内做语音转文字的场景,原生框架确实省掉了大量维护成本和网络开销。如果你只是做一个语音备忘录、会议记录、或者输入框的语音辅助功能,原生方案完全够用。
1.2 与第三方SDK的选型对比
这里把几个主流方案横向对比一下。第三方云端SDK我实际也用过,比如国内比较常见的几家云服务商的语音转写能力,它们有各自的SDK。这类方案通常准确率不错,中文场景支持也很好,但会引入几个问题:需要注册开发者账号拿密钥、需要依赖对方的网络接口、音频数据会经过第三方服务器、且免费额度用完后费用不低。
自建模型方案我也评估过:在端侧部署一个轻量级的语音识别模型,比如用一些开源模型做推理。这条路的好处是数据完全可控、离线能力最强,但工程复杂度直线上升:模型训练需要大量标注数据、准确率调优周期长、包体积和内存占用都是App的负担,对绝大多数业务场景来说属于杀鸡用牛刀。
对比之下,原生Speech方案的优势非常明显:零成本接入、不依赖额外服务器、API设计精炼、与系统权限体系天然整合。它的短板在于:长句复杂场景下的准确率不如专门调优过的云端方案,且系统在极短时间内的并发请求有速率限制,不适合做持续数小时不间断的录音转写。但只要你的场景是日常对话级别的实时听写,原生方案的表现足够让人满意。
| 对比维度 | 原生Speech | 第三方云端SDK | 自建端侧模型 |
|---|---|---|---|
| 接入成本 | 低,无账号 | 中,需注册对接 | 高,训练部署复杂 |
| 费用 | 免费 | 按量计费或有免费额度 | 开发成本为主 |
| 数据隐私 | 本地优先 | 走第三方服务器 | 完全本地 |
| 中文识别准确率 | 日常对话够用 | 更优 | 取决于模型 |
| 包体积影响 | 几乎无 | 增加少量 | 增加明显 |
| 维护成本 | 低 | 需跟踪接口变更 | 高 |
我最后选择原生方案的直接原因,其实是项目里对数据隐私有要求,不想把用户音频未经处理就传到第三方服务器。苹果的语音识别整体上以设备本地处理为设计倾向,即便部分能力需要联网增强,权限提示也是系统级的、用户能感知的,这对我过审和用户信任都有帮助。
2. 工程配置与权限准备
2.1 引入框架与Info.plist配置
接入Speech框架,第一步不是写代码,而是把工程配置弄好。用Xcode新建项目后,在Target的Signing & Capabilities或者直接通过import语句引入模块。Swift里只要写一句:
import Speech import AVFoundationAVFoundation不是Speech框架的一部分,但它负责录音,所以必须一起引入。如果你还想让App在后台也能录音,还得在Capabilities里开启Background Modes,勾选Audio、AirPlay and Picture in Picture选项,不过这个按需来,常规场景不需要。
接下来是Info.plist配置。语音识别涉及两个敏感权限,缺一不可:一个是NSSpeechRecognitionUsageDescription,另一个是NSMicrophoneUsageDescription。前者用于说明为什么要用语音识别,后者用于说明为什么要用麦克风。App Store审核时会核对这两条描述是否跟你实际功能匹配,不要写模糊的“用于提升用户体验”这种套话,把你真实的使用场景写清楚,比如“用于将您的语音实时转换为文字以完成会议记录”。
<key>NSSpeechRecognitionUsageDescription</key> <string>用于将您的语音实时转换为文字,实现语音输入功能</string> <key>NSMicrophoneUsageDescription</key> <string>用于采集您的语音以进行识别和转写</string>这两条描述千万不能省,否则运行时申请权限会直接崩溃,而且崩溃日志里报错很隐蔽,新手容易在这里卡半天。
2.2 权限申请流程与状态处理
Speech框架的权限请求是异步的,调用SFSpeechRecognizer.requestAuthorization后会返回一个闭包,闭包里给你SFSpeechRecognizerAuthorizationStatus枚举,一共四种状态:notDetermined(还没请求过)、denied(用户拒绝)、restricted(受控限制,比如家长控制)、authorized(已授权)。
常规做法是在App启动后、用户触发语音功能前就主动申请权限。需要说明的是,申请麦克风权限(AVAudioSession)和语音识别权限是两个独立的弹窗,顺序是:先请求语音识别权限,再请求麦克风权限。如果你顺序反了,用户会连续看到两个权限弹窗,体验较差,而且部分情况下会出现权限状态互相竞争的问题。
SFSpeechRecognizer.requestAuthorization { authStatus in DispatchQueue.main.async { switch authStatus { case .authorized: print("语音识别权限已授权") case .denied: print("用户拒绝语音识别权限") case .restricted: print("语音识别受限") case .notDetermined: print("尚未请求权限") @unknown default: break } } }这里特别提醒一下:权限弹窗不是随调随出的。系统对权限弹窗有高频请求抑制机制,如果用户在短时间内在系统设置里反复开关权限,弹窗可能不出现,状态直接返回denied。我在开发时遇到过一次,用户投诉说点击按钮没反应,最后排查发现是用户在测试时频繁触发权限申请导致的系统级限制,只能引导用户去设置页手动开启。所以代码里要对denied状态做出友好引导,跳转系统设置是一个必须做的兜底方案。
3. 核心实现:录音与逐段识别
3.1 录音引擎的基础搭建
录音这块AudioKit等第三方封装虽然好用,但不必要,AVAudioEngine本身就够用。核心思路:创建一个AVAudioEngine实例,从它的inputNode(输入节点)获取麦克风音频流,通过installTap方法把每一帧音频Buffer取出来,再交给语音识别请求对象。
在实际写代码之前,有几个概念要先理清。AVAudioEngine本身是一个音频处理图的容器,inputNode代表麦克风输入,还有一个mixer、outputNode等节点。语音识别场景下我们不需要做任何音频处理,只需要拿到原始音频Buffer,所以直接在inputNode上安装一个tap回调即可。注意:tap是持续触发的,每个音频帧都会回调一次,频率非常高,回调里只做最简单的Buffer转发,不要在这里做任何耗时操作。
import AVFoundation final class SpeechRecorder { private let audioEngine = AVAudioEngine() private var inputNode: AVAudioInputNode { return audioEngine.inputNode } func startRecording() throws { // 先配置会话,保证麦克风可用 let audioSession = AVAudioSession.sharedInstance() try audioSession.setCategory(.record, mode: .measurement, options: [.duckOthers]) try audioSession.setActive(true, options: .notifyOthersOnDeactivation) // 启动引擎前,先移除旧tap,避免重复安装 inputNode.removeTap(onBus: 0) // 安装tap,采集音频Buffer let recordingFormat = inputNode.outputFormat(forBus: 0) inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { buffer, when in // 这里将buffer交给语音识别请求 self.recognitionRequest?.append(buffer) } audioEngine.prepare() try audioEngine.start() } }demo里大家最常抄到的代码片段,通常就是这样一个installTap的写法。我在实际项目里照着抄时踩过一个坑:直接用了当时的outputFormat作为录音格式,在部分旧设备上会因为这个采样率格式不被识别而直接崩溃。稳妥的做法是判断音频格式是否包含valid channels,如果不支持,就手动指定一个标准的PCM格式,比如16000Hz单声道。不过在新版iOS上,直接取outputFormat的问题大多不存在了,你可以先按标准写法来,如果测试机上崩了再去兼容。
3.2 创建识别请求与启动识别任务
录音数据流有了,接下来就是把它喂给SFSpeechRecognizer。具体到代码上,先创建一个SFSpeechAudioBufferRecognitionRequest实例,它就是承载音频流的容器。然后调用SFSpeechRecognizer的recognitionTask方法,传入这个request,并提供一个handler来处理识别结果。
SFSpeechAudioBufferRecognitionRequest有两个关键属性值得注意:shouldReportPartialResults用于控制是否实时返回部分识别结果,开为true时,用户边说边出字,体验接近实时听写;开为false时,只返回最终完整结果。大部分场景建议开启partial,但如果你是做“语音命令词”这种短指令识别,可以关掉partial,等完整结果出来再匹配,减少UI闪烁。另一个是taskHint,它是一个枚举,告诉系统这次识别任务的类型。默认是.unspecified,如果你想识别的是用户搜索内容,可以设成.search,想识别听写文本则设成.dictation,系统会针对性地做语言模型调优,不过实测效果差异不算特别明显。
完整初始化:
import Speech final class SpeechRecognizerManager { private let recognizer = SFSpeechRecognizer(locale: Locale(identifier: "zh-CN")) private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? private var recognitionTask: SFSpeechRecognitionTask? func setupRecognition() throws { // 重置旧的请求和任务 recognitionTask?.cancel() recognitionTask = nil recognitionRequest = nil guard let recognizer = recognizer, recognizer.isAvailable else { throw SpeechError.unavailable } let request = SFSpeechAudioBufferRecognitionRequest() request.shouldReportPartialResults = true request.taskHint = .dictation request.requiresOnDeviceRecognition = false recognitionRequest = request recognitionTask = recognizer.recognitionTask(with: request) { result, error in if let result = result { let text = result.bestTranscription.formattedString DispatchQueue.main.async { // 更新UI上的识别文本 self.displayText(text) } if result.isFinal { // 识别结束,做一些收尾处理 } } if let error = error { print("识别错误: \(error.localizedDescription)") } } } }用心看这段代码会发现,我加了一个requiresOnDeviceRecognition参数,默认值是false。这个参数的作用是强制是否只在设备上进行识别,如果设为true,音频数据完全不会离开设备,隐私保护拉到最满,但准确率和响应速度会有一定代价,且部分语言包未下载时直接不可用。如果你的App对隐私要求极高又舍得牺牲一点体验,可以设置为true;否则保持默认,让系统自己判断是本地还是使用网络增强。
3.3 一个可直接运行的最小完整Demo
工程到这一步,很多设计上的零散点容易把人绕晕,我给你拼一个可直接落地的完整管理器类。这个类负责:权限申请、开始录音识别、停止录音、结束识别,以及对外抛结果回调。实际写在项目里,会比你在网上看到的片段更贴实战。
import Speech import AVFoundation enum SpeechError: LocalizedError { case recognizerUnavailable case audioSessionFailed case recognizerError var errorDescription: String? { switch self { case .recognizerUnavailable: return "语音识别服务不可用,请检查语言支持" case .audioSessionFailed: return "录音会话配置失败" case .recognizerError: return "语音识别发生错误" } } } final class SpeechManager: NSObject { private let audioEngine = AVAudioEngine() private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? private var recognitionTask: SFSpeechRecognitionTask? private let speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "zh-CN")) var onPartialResult: ((String) -> Void)? var onFinalResult: ((String) -> Void)? var onError: ((Error) -> Void)? var isRecording: Bool { return audioEngine.isRunning } // 1. 权限申请 static func requestAuthorization(completion: @escaping (Bool) -> Void) { SFSpeechRecognizer.requestAuthorization { status in DispatchQueue.main.async { switch status { case .authorized: completion(true) default: completion(false) } } } } // 2. 麦克风权限申请,可以单独封装一个方法 func requestMicPermission(completion: @escaping (Bool) -> Void) { AVAudioSession.sharedInstance().requestRecordPermission { granted in DispatchQueue.main.async { completion(granted) } } } // 3. 开始录音识别 func start() throws { // 如果已经在录制,先停止 if audioEngine.isRunning { stop() } // 检查识别器可用性 guard let speechRecognizer = speechRecognizer, speechRecognizer.isAvailable else { throw SpeechError.recognizerUnavailable } // 配置音频会话 let audioSession = AVAudioSession.sharedInstance() do { try audioSession.setCategory(.record, mode: .measurement, options: [.duckOthers]) try audioSession.setActive(true, options: .notifyOthersOnDeactivation) } catch { throw SpeechError.audioSessionFailed } // 创建识别请求 let request = SFSpeechAudioBufferRecognitionRequest() request.shouldReportPartialResults = true request.taskHint = .dictation recognitionRequest = request // 开始识别任务 recognitionTask = speechRecognizer.recognitionTask(with: request) { [weak self] result, error in guard let self = self else { return } if let result = result { let text = result.bestTranscription.formattedString DispatchQueue.main.async { self.onPartialResult?(text) } if result.isFinal { DispatchQueue.main.async { self.onFinalResult?(text) } self.stop() } } if let error = error { DispatchQueue.main.async { self.onError?(error) } self.stop() } } // 安装音频tap let inputNode = audioEngine.inputNode let recordingFormat = inputNode.outputFormat(forBus: 0) inputNode.removeTap(onBus: 0) inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { [weak self] buffer, _ in self?.recognitionRequest?.append(buffer) } audioEngine.prepare() try audioEngine.start() } // 4. 停止录音识别 func stop() { audioEngine.stop() audioEngine.inputNode.removeTap(onBus: 0) recognitionRequest?.endAudio() recognitionRequest = nil recognitionTask?.cancel() recognitionTask = nil } }这个类涵盖了最常见的使用方式。调用方在按钮点击事件里,先要求权限,再start();识别过程中通过onPartialResult实时刷新TextView,停止时用onFinalResult拿到完整文本。stop方法里注意两个细节:要先调audioEngine.stop,再调request.endAudio(),顺序反了会出现音频数据没送完就被截断的问题;另外每次stop后要重置recognitionRequest和recognitionTask,否则下一次start会复用旧对象导致崩溃。
3.4 UI交互与状态管理
录音和识别的状态变化比普通接口调用要复杂一些,我建议在UI层面把状态封装成一个简单的状态机,而不是到处用布尔值拼凑。常见的状态包括:空闲(idle)、录音中(recording)、处理中(processing)、错误(error)。
状态机里最值得注意的一点是:用户在界面上点停止按钮,和系统因为识别完成自动停止,是两个不同的路径。如果你只依赖audioEngine.isRunning去判断状态,会在某些边界情况下出错,因为音频引擎停止后系统还在做最后的文本整理。我通常用一个额外的枚举变量维护状态,在识别回调中收到isFinal时强制置为idle,并且把UI按钮重置为可点击的“开始录音”状态。同时为了避免用户在音频引擎正在停止的过程中反复点击,停止方法加一个防抖逻辑:
private var isStopping = false func requestStop() { guard !isStopping else { return } isStopping = true stop() DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in self?.isStopping = false } }另外,识别结果是一个逐渐变长的字符串,直接每次都整体赋值给Label,在长文本场景下会造成明显卡顿。比较实用的做法是:借助attributedText做增量更新,或者干脆用TextView并利用Range替换已更新部分。实测下来,只要把更新放主线程,并且避免频繁创建NSAttributedString,普通长度的文本是没问题的。
4. 常见问题与性能优化
4.1 权限相关的典型坑
语音识别和麦克风两个权限的联动问题,是我见过初学者最容易踩坑的重灾区。最典型的情况是:用户已经授权过麦克风给其他App,但你的App首次请求语音识别权限时,系统弹的是别的东西?不会,系统逻辑很简单,两个权限独立弹窗。但如果你代码里把两个权限申请写到同一个按钮事件里连续触发,极大概率会出现第二个弹窗被系统打断。我还遇到过一个更隐蔽的问题:调用了audioSession.setActive(true)但没给录音权限,音频引擎start会直接抛异常,结果错误并不是“无权限”,而是“操作不允许”。排查半天发现其实是没有先申请麦克风权限。
权限申请的正确姿势:页面加载后先检查两个权限状态,如果未决定,可以先弹一个系统授权引导弹窗(自己写的,带取消按钮),用户点确认后再调requestRecordPermission与SFSpeechRecognizer.requestAuthorization。两个权限的请求间隔最好控制在0.5秒以上,实测多次之后这个策略明显减少了弹窗丢失问题。
4.2 识别准确率与中文场景调优
中文识别方面,Speech框架做的还不错,但要认清一个边界:它很适合普通话和常用口语表达,但在专业术语、生僻词、多音字场景,准确率会有明显下降。举个例子,我项目里用户经常说“iOS”这个词,但系统转出来的有时候是“i o s”有时候是“爱哦死”,完全随缘。
系统没有提供热词表接口,所以提升准确率的思路要变通。我实际验证有效的方案有两个。第一:识别结果出来后,先用preferred overrides列表做一次本地替换,比如把常见误识别词映射到正确词。第二:用NSLinguisticTagger做分词,再配合用户自定义词库改写。这个方案的原理是:语音识别引擎输出文本后,我们再基于上下文做后处理纠错,减少专业词汇的错字率。
标点符号方面,SFSpeechRecognitionResult的bestTranscription里,如果你用formattedString属性拿到的是一段无标点的纯文本,系统其实在transcriptions数组里附带了每个词的时间边界等信息,但不会自动帮你说“逗号”。想要让识别的文本带着自然的标点停顿,目前原生方案做不到特别细腻,常规做法是识别完成后用自己训练的标点模型或者简单的规则(比如停顿时间超过0.8秒插入逗号)做后处理。如果你的产品定位是“语音备忘录”,这步优化值得做,用户体验会有明显差别。
4.3 性能、内存与生命周期管理
语音识别运行期间,内存占用会有一个明显的攀升,尤其是在长时间录音场景下。这背后的原因是AVAudioEngine在持续往识别请求里追加音频Buffer,系统内部会为上下文保存一定量的音频信息。如果你的App里用户可能长时间连续录音(比如一小时以上),我建议把快照策略换成“每五分钟停止一次、重新开启下一段识别”的模式,这样既避免单次任务过于庞大,也更符合苹果对识别任务时长的隐性限制。
音频会话也是一个容易被忽视的内存泄漏点。每次调用audioSession.setActive(true)之后,一定要在stop里成对调用setActive(false),并加上.notifyOthersOnDeactivation选项。否则用户在录音结束后播放视频,声音会变得很小甚至无声,因为你的App没有及时释放音频会话的独占权。这个问题几乎每个刚接录音的开发者都会碰上。
另外,recognitionTask对象一定要妥善持有。它是NSObject的子类,如果被提前释放,识别回调会直接中断。很多人在闭包里捕获了self导致循环引用,又在deinit里没有清理,就会出现“退出页面还在识别”的情况。我的习惯是:在viewWillDisappear或sceneDidEnterBackground回调里统一调用stop(),并且把speechRecognizer也置为nil,防止后台识别导致被系统杀掉。苹果对后台识别是有限制的,App退到后台后,音频采集会被系统暂停,如果识别任务还没取消,恢复前台时会有一堆回调同时触发,不处理干净就是一场灾难。
4.4 常见问题速查表
把上面这些坑整理成一个速查表,方便你在开发时快速定位问题。
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| 弹窗不出现 | 系统高频请求抑制 | 引导用户去设置页手动开启 |
| 启动录音崩溃 | 未申请麦克风权限 | 先requestRecordPermission再启动引擎 |
| 识别不返回结果 | 识别器不可用或locale不支持 | 检查SFSpeechRecognizer.isAvailable |
| 录音没声音 | AVAudioSession未设置或未激活 | 设置.record模式并setActive(true) |
| 退后台后识别停止 | 系统暂停音频采集 | 监听sceneDidEnterBackground并提前停止 |
| 文字一次性全出 | shouldReportPartialResults为false | 改为true实现实时显示 |
| 识别结果丢尾巴 | stop后马上endAudio | 先停止引擎再endAudio,顺序不要反 |
| 内存不断上涨 | 长时间未重置识别请求 | 分段识别,或定期重建request对象 |
| 中文专业词不准 | 无热词机制 | 后处理替换 + 分词纠错 |
5. 从Demo到上线需要补的几个细节
如果你照着上面的Manager类做完了本地测试,会发现功能已经能跑通了,但离真正上线的产品还有一些细节要补。第一个问题:网络状态。语音识别在某些联网增强模式下会消耗流量,Apple的API没有提供直接查询“本次识别是否在设备本地完成”的能力,但你可以通过观察请求错误码间接判断。用户如果在飞行模式下使用,识别可能会失败或者结果质量变差,这时候UI层需要给出“离线模式识别质量下降”的提示,而不能默默失败。
第二个问题:用户输入的长文本编辑。语音识别生成的文字不是产品终点,用户一定会去修改。如果你的页面只有一个TextView,语音识别回调覆盖文本时要注意保留光标位置。最简单的方式是:每次识别结果回来后,记录当前的光标偏移量,用UITextView的selectedRange做增量更新,而不是整体赋值。
第三个问题:多语言支持。SFSpeechRecognizer(locale:)里的locale参数,并不是随便传一个字符串就能生效。它依赖系统是否下载了对应语言的语音包。如果用户设备上没有中文语音包,系统会自动跳转下载引导?并不完全自动,你的代码需要处理语音包未安装的情况,建议在开始识别前先检查supportedLocales并给出提示。
第四个问题:测试耳机与真机差异。模拟器上Speech框架基本是废的,识别效果为零,必须在真机上测试。真机上如果用户连着有线耳机,麦克风走的是耳机口的输入,老化耳机的底噪会严重影响识别率。如果你做的是语音笔记类App,建议强制使用设备内置麦克风,或者至少在设置里给出切换选项。
这些细节没法全写在一个Demo里,但它们是决定你的语音识别功能从“能跑”到“好用”的分水岭。我在自己的项目里一步步把这些补全后,用户反馈里关于“识别不准确”和“用起来怪怪的”这两类声音明显少了。
6. 一些实测的数据与心得
最后说说我在真机上的实测情况,机型是近年某款中端iPhone,系统保持在当前主流版本。在安静环境下,中文普通话短句(5-15个字)识别准确率非常高,基本一次过,偶尔有同音字错误。稍微嘈杂的环境(比如马路边)准确率会掉到八成左右,但还不至于不可用。安静环境下的长时间听写(2-3分钟连续表达),整体准确率我认为可以用于语音输入场景,比键盘打字快得多。
在优化过程中我发现,麦克风距离对识别结果的影响比想象中大,用户如果习惯把手机放桌上说话,识别率会有明显下降,而把手机拿近时识别率又回来了。这说明原生Speech框架对信噪比比较敏感。产品层面如果想让用户用得舒服,可以在引导文案里加一句“请靠近麦克风说话”,看起来是废话,实际效果立竿见影。
关于并发限制,苹果对单台设备上的语音识别请求频率是有隐形控制的,短时间连续发起过多任务会自动失败。我自己测试时发现,一分钟内创建了十几次识别任务就会出现报错。所以在停止和重新开始的逻辑里,一定要做好节流,必要时给用户一个“休息一下”的交互提示。
我个人在实际开发中体会最深的一点是:原生语音识别并不是“调好接口就完事”,它更像是一个需要跟用户习惯磨合的功能。代码只是基础,真正拉开体验差距的地方在于权限引导设计、结果后处理、以及边界状态的兜底。如果你正在做的App正好需要一个稳定的语音输入能力,给原生方案一个机会,它会比你想的更可靠。