☰
Android智能语音开发入门:从语音识别到语义解析的完整链路
2026/10/20 3:37:45 网站建设 项目流程

1. 从零理解 Android 智能语音:它到底在解决什么问题

很多人第一次接触 Android 智能语音开发,脑子里冒出来的第一个念头是“我要做一个能跟人对话的 App”。这个想法没错,但太笼统了。真正落到工程上,智能语音在 Android 端要解决的核心问题其实可以拆成三件事:把声音变成文字、把文字变成意图、把意图变成动作或回应。这三件事分别对应语音识别(ASR)、自然语言理解(NLU)和语音合成(TTS)。新手最容易犯的错,就是一上来就想把三件事揉在一起做,结果每个环节都半生不熟,调试起来一头雾水。

我刚开始做语音相关功能的时候,也走过弯路。当时接了一个需求,要做一个“语音记账”的小工具,用户说一句“今天午饭花了三十块”,App 要自动识别金额和类别并记一笔账。我第一版直接把录音丢给识别接口,拿到文字后自己写正则去抠数字,结果遇到“三十”“三十块”“三十块钱”“花了三十”这些说法就频繁翻车。后来才明白,识别和语义解析是两层完全不同的活儿,识别负责“听清”,语义解析负责“听懂”,混在一起写只会让代码越来越乱。

所以这篇内容适合谁看?如果你是有一定 Android 基础(会写 Activity、懂权限申请、用过 Gradle),但从来没碰过语音功能的开发者,那这篇就是为你准备的。我会从最基础的环境搭建讲起,一路讲到识别、合成、语义解析的串联,中间穿插我自己踩过的坑和实测有效的参数配置。如果你已经做过语音功能,也可以看看后面关于离线识别、音频焦点、权限适配那几节,那些是新手教程里通常不会细讲、但实际项目里一定会遇到的东西。

需要先说明一点:Android 上的语音能力来源主要有两条路。一条是调用系统自带的识别与合成服务(通过RecognizerIntent和TextToSpeech),另一条是接入第三方或自研的语音 SDK。两条路各有取舍,我会在第 2 节里用表格把它们的差异讲清楚,帮你根据项目实际情况做选择。新手建议先从系统自带的能力入手,因为它不需要申请额外的账号和密钥,跑通流程最快,等理解了整个链路之后再换更强的引擎也不迟。

2. 动手前的关键选型:系统能力还是第三方引擎

2.1 两条技术路线的真实差异

在写第一行代码之前,选型这件事必须先想明白。我见过不少新手直接抄了一份第三方 SDK 的 Demo,跑通了就以为万事大吉,结果项目上线后发现包体积暴涨、部分机型识别不了、离线场景直接罢工。这些问题的根源,都是选型阶段没考虑清楚。

系统自带能力和第三方引擎的差异,我整理成下面这张表,你可以对照自己的项目需求来判断:

对比维度系统自带能力第三方/自研引擎
接入成本极低,无需账号密钥需要注册、配置 AppKey
包体积影响几乎为零通常增加数 MB 到数十 MB
识别准确率中等,依赖设备厂商较高,模型持续优化
离线支持部分设备支持,不稳定可选购离线能力
语义理解无,需自己实现多数附带 NLU 能力
定制化程度低高,可定制词库和模型
隐私合规数据走向依赖系统实现需自行评估数据链路

从这张表能看出来,系统能力适合快速验证和轻量场景,第三方引擎适合对准确率和定制化有要求的正式产品。我个人的建议是:新手入门阶段一律先用系统能力把链路跑通,因为你需要理解的是“语音功能是怎么串起来的”,而不是“哪个引擎识别率高”。等你把识别、合成、权限、音频焦点这些基础问题都趟过一遍,再换引擎就是替换一个模块的事,不会伤筋动骨。

2.2 环境准备中最容易被忽略的三件事

环境准备看起来简单,但有三件事新手几乎必踩。

第一件是权限。语音识别需要录音权限,这个大家都知道,但很多人忘了在AndroidManifest.xml里声明RECORD_AUDIO,也忘了在运行时动态申请。Android 6.0 之后,危险权限必须运行时申请,只在清单里声明是不够的。更坑的是,有些设备在用户拒绝一次后会默认记住“不再询问”,第二次申请直接返回拒绝,所以申请前最好先用shouldShowRequestPermissionRationale判断一下,给用户一个解释。

第二件是设备的语音服务是否可用。系统识别能力依赖设备上安装的语音服务,有些定制系统或者精简过的设备根本没有预装,这时候SpeechRecognizer.isRecognitionAvailable(context)会返回 false。如果你不判断这个就直接调用,程序会直接崩。我实测下来,主流品牌的手机基本都有,但模拟器和部分海外版设备经常缺失,所以这个判断一定要加。

第三件是网络状态。系统识别默认走在线识别,没网就废。虽然部分设备支持离线识别,但需要提前下载离线语言包,而且不是所有设备都开放这个能力。所以如果你的功能强依赖语音,最好在 UI 上给用户一个明确的网络提示,而不是让用户对着手机喊半天没反应。

<!-- AndroidManifest.xml 中必须声明 --> <uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

注意:INTERNET权限虽然是普通权限,但语音识别走在线服务时必须有它,漏了会导致识别请求直接失败,而且报错信息往往很模糊,不容易定位。

2.3 依赖配置与最小可运行工程

系统能力不需要额外依赖,但如果你打算用 AndroidX 的兼容库来处理权限和生命周期,建议在build.gradle里加上下面这些。这些库能帮你省掉大量样板代码,尤其是权限申请那块。

dependencies { implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.activity:activity-ktx:1.8.2' implementation 'com.google.android.material:material:1.11.0' }

建工程的时候,语言选 Kotlin 还是 Java 都行,我下面用 Kotlin 演示,因为它的协程和回调处理写起来更清爽。最低 SDK 版本建议定在 23(Android 6.0),这样运行时权限的模型是统一的,不用再为老版本写兼容分支。目标 SDK 尽量往新版本靠,因为新版本对后台启动、录音权限的管控更严,早点适配能避免上线前才发现问题。

3. 语音识别链路:从按下按钮到拿到文字

3.1 SpeechRecognizer 的初始化与生命周期

系统识别的核心类是SpeechRecognizer。它的创建有个硬性要求:必须在主线程创建。这一点很多人不知道,在子线程里 new 一个出来会直接抛异常。创建之后,它和调用它的 Context 是绑定的,所以生命周期要跟着 Activity 或 Fragment 走,在onDestroy里记得调用destroy()释放,否则会泄漏。

class VoiceActivity : AppCompatActivity() { private lateinit var speechRecognizer: SpeechRecognizer private lateinit var recognitionListener: RecognitionListener override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_voice) if (!SpeechRecognizer.isRecognitionAvailable(this)) { Toast.makeText(this, "当前设备不支持语音识别", Toast.LENGTH_LONG).show() finish() return } speechRecognizer = SpeechRecognizer.createSpeechRecognizer(this) recognitionListener = object : RecognitionListener { override fun onReadyForSpeech(params: Bundle?) { // 可以在这里更新 UI,提示用户开始说话 } override fun onBeginningOfSpeech() {} override fun onRmsChanged(rmsdB: Float) { // 音量变化,可用来做波形动画 } override fun onBufferReceived(buffer: ByteArray?) {} override fun onEndOfSpeech() {} override fun onError(error: Int) { // 错误处理,见 3.3 节 } override fun onResults(results: Bundle?) { val list = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) val text = list?.firstOrNull() ?: "" // 拿到识别文字 } override fun onPartialResults(partialResults: Bundle?) { val list = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) // 实时中间结果,可用来做“边说边显示” } override fun onEvent(eventType: Int, params: Bundle?) {} } speechRecognizer.setRecognitionListener(recognitionListener) } override fun onDestroy() { speechRecognizer.destroy() super.onDestroy() } }

这段代码里有个细节值得说:onPartialResults返回的是中间结果,也就是用户还没说完时系统已经识别出的部分文字。这个回调非常适合做“边说边显示”的效果,用户体验会好很多。但要注意,中间结果不一定准确,最终还是要以onResults为准。我一般会把中间结果用浅色显示,最终结果用深色覆盖,视觉上很自然。

3.2 Intent 参数的调优:让识别更贴合你的场景

启动识别是通过一个Intent来配置的,参数调得好不好,直接影响识别效果。下面这几个参数是我实测下来最值得调的:

val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) putExtra(RecognizerIntent.EXTRA_LANGUAGE, "zh-CN") putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true) putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 3) putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS, 1500L) putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_MINIMUM_LENGTH_MILLIS, 1000L) } speechRecognizer.startListening(intent)

EXTRA_LANGUAGE_MODEL有两个取值,FREE_FORM适合自由说话,WEB_SEARCH适合搜索词。做记账、指令这类场景,用FREE_FORM更合适。EXTRA_LANGUAGE指定中文,不指定的话有些设备会按系统语言走,用户说中文识别成英文的尴尬情况就是这么来的。

EXTRA_MAX_RESULTS设成 3 是个经验值。系统会返回多个候选结果,第一个通常最准,但遇到同音词时第二个、第三个可能才是对的。我一般会把候选结果都展示出来让用户选,尤其是涉及金额、人名这类关键信息时,多给几个选项能显著降低出错率。

静音检测那两个参数是控制“用户停顿多久算说完”的。默认值有时候偏短,用户思考一下就被判定说完了,体验很差。设成 1500 毫秒是我试过比较舒服的值,既不会让用户等太久,也不会频繁误判。

3.3 识别错误的分类处理与用户提示

onError回调里的错误码有十几种,新手最容易犯的错是统一弹一句“识别失败”,用户完全不知道发生了什么。实际上不同错误码对应的处理方式完全不同,我整理了一张对照表:

错误码含义建议处理
ERROR_NO_MATCH没识别到匹配内容提示“没听清,请再说一遍”
ERROR_SPEECH_TIMEOUT超时没检测到语音提示“没有检测到声音”
ERROR_RECOGNIZER_BUSY识别器忙稍后重试,不要立即重启
ERROR_INSUFFICIENT_PERMISSIONS权限不足引导去设置页开权限
ERROR_NETWORK网络错误检查网络并提示
ERROR_CLIENT客户端错误通常是调用顺序问题,检查代码

ERROR_RECOGNIZER_BUSY这个坑我要特别说一下。如果你在识别还没结束时又调了一次startListening,就会触发这个错误。正确的做法是维护一个状态标志,识别中就不允许再次启动,或者先cancel()再重新开始。我早期写的代码就是因为用户手快连点两下按钮,直接卡死,排查了半天才发现是这个原因。

ERROR_CLIENT也很常见,多数情况是你没有先setRecognitionListener就调了startListening,或者destroy()之后又调用了识别器的方法。这类问题看日志里的堆栈基本能定位,不要盲目改代码。

4. 语音合成:让 App 开口说话

4.1 TextToSpeech 的初始化陷阱

语音合成用TextToSpeech类,它的初始化是异步的。很多人写完TextToSpeech(context, listener)就以为可以用了,结果调speak没声音。原因是初始化还没完成,必须等OnInitListener回调返回SUCCESS之后才能用。

private var tts: TextToSpeech? = null private var ttsReady = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) tts = TextToSpeech(this) { status -> if (status == TextToSpeech.SUCCESS) { val result = tts?.setLanguage(Locale.CHINESE) if (result == TextToSpeech.LANG_MISSING_DATA || result == TextToSpeech.LANG_NOT_SUPPORTED) { // 中文不支持,需要提示或降级 } else { ttsReady = true } } } }

这里有个隐藏的坑:setLanguage返回LANG_MISSING_DATA表示设备缺少中文语音数据。这种情况在部分海外版设备上很常见。遇到这种情况,你可以引导用户去系统设置里下载语音数据,或者干脆降级成只显示文字不发声。我一般会在初始化失败时给一个温和的提示,而不是直接让功能不可用。

4.2 语速、音调与发音人选择

setSpeechRate和setPitch是两个很实用的调节项。语速默认是 1.0,范围大概在 0.5 到 2.0 之间。做播报类功能时,我一般会把语速调到 0.9 左右,听起来更从容;做提示音时调到 1.1,显得干脆。音调默认也是 1.0,调高会显得年轻,调低会显得沉稳,但别调太极端,否则会失真。

tts?.setSpeechRate(0.9f) tts?.setPitch(1.0f) tts?.speak("记账成功,午饭三十元", TextToSpeech.QUEUE_ADD, null, "utteranceId")

QUEUE_ADD和QUEUE_FLUSH的区别要搞清楚。QUEUE_ADD是把新内容加到队列末尾,前面的说完再说;QUEUE_FLUSH是打断当前播放,立刻说新的。做连续播报用前者,做即时提醒用后者。我见过有人做导航播报时用了QUEUE_ADD,结果用户已经拐弯了,语音还在念上一条,体验很糟糕。

发音人选择这块,getVoices()能拿到设备支持的所有发音人,你可以按语言和地区筛选。不过要注意,不同设备的发音人列表差异很大,不要硬编码某个发音人的名字,否则换台设备就找不到。

4.3 合成与识别的音频焦点冲突

这是新手几乎必踩的坑:识别和合成同时进行时,会互相抢音频焦点。典型场景是用户说完一句话,App 识别完立刻用 TTS 回应,结果 TTS 的声音被识别器当成输入又识别了一遍,形成死循环。

解决办法是在启动识别前先tts?.stop(),在 TTS 播放时不要启动识别。更稳妥的做法是监听 TTS 的播放状态,用UtteranceProgressListener在onDone回调里再启动下一轮识别。

tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() { override fun onStart(utteranceId: String?) {} override fun onDone(utteranceId: String?) { runOnUiThread { // 播报结束,可以安全启动下一轮识别 } } override fun onError(utteranceId: String?) {} })

这个回调是在子线程触发的,更新 UI 记得切回主线程。我当初就是忘了切线程,直接在里面操作了控件,偶发崩溃查了好久。

5. 从识别文字到真正“听懂”:语义解析的落地做法

5.1 为什么不能只靠正则

拿到识别文字只是第一步,真正难的是从文字里提取出结构化信息。前面提到的记账场景,用户可能说“午饭三十”“今天中午吃饭花了三十块”“三十块钱的午饭”,这些说法千变万化,纯正则根本覆盖不过来。

我的做法是分层解析:第一层做关键词匹配,识别出“意图”(是记账、查询还是删除);第二层做实体抽取,把金额、类别、时间抠出来。关键词匹配用简单的包含判断就行,实体抽取则要结合词库和规则。

data class ParseResult( val intent: String, val amount: Double?, val category: String?, val rawText: String ) fun parse(text: String): ParseResult { val intent = when { text.contains("花") || text.contains("记") -> "add" text.contains("查") || text.contains("多少") -> "query" else -> "unknown" } val amount = extractAmount(text) val category = extractCategory(text) return ParseResult(intent, amount, category, text) }

5.2 金额与类别的抽取技巧

金额抽取是重灾区。中文里“三十”“三十块”“30”“三十元”都要能认。我的做法是先把中文数字转成阿拉伯数字,再用正则匹配数字加单位。中文数字转换这块,网上有现成的工具类,但要注意“两”和“二”的处理,“两百”和“二百”是一个意思。

private val chineseNumMap = mapOf( '零' to 0, '一' to 1, '二' to 2, '两' to 2, '三' to 3, '四' to 4, '五' to 5, '六' to 6, '七' to 7, '八' to 8, '九' to 9 ) fun chineseToNumber(text: String): Int? { // 简化版,处理“三十”“二十五”这类 var result = 0 var temp = 0 for (c in text) { when (c) { '十' -> temp = if (temp == 0) 10 else temp * 10 '百' -> temp *= 100 else -> { val n = chineseNumMap[c] ?: return null temp += n } } } result += temp return if (result == 0) null else result }

类别抽取靠词库映射。建一个“午饭、早餐、晚餐、打车、地铁、购物”到“餐饮、交通、购物”的映射表,识别文字里出现哪个词就归到对应类别。词库要持续维护,用户说什么就补什么,这是个体力活但很有效。

5.3 置信度与二次确认

识别结果不一定准,所以关键操作一定要有二次确认。我的做法是:解析出金额和类别后,用 TTS 复述一遍“你要记一笔餐饮支出三十元,对吗”,用户确认后再真正写入。这样即使识别错了,用户也有机会纠正,不会产生错误数据。

复述的时候,把关键信息用重音强调,比如“餐饮支出三十元”,用户一听就能判断对不对。这个细节看起来小,但实际用起来体验差别很大。

6. 实战中绕不开的适配与稳定性问题

6.1 不同 Android 版本的权限与后台限制

Android 版本差异是语音功能稳定性的最大敌人。Android 10 之后,后台应用不能随意访问麦克风;Android 11 之后,一次性权限和自动重置权限的机制又带来了新的变数。如果你的语音功能需要在后台运行,必须申请前台服务,并在通知栏常驻一个提示。

<service android:name=".VoiceService" android:foregroundServiceType="microphone" />

foregroundServiceType这个属性在 Android 10 之后必须声明,否则启动前台服务会崩。而且从 Android 14 开始,前台服务的类型声明要求更严格,用错了类型也会被拒。这块建议直接查最新的官方文档,因为每个版本都在收紧。

6.2 音频焦点与打断处理

多个 App 同时用麦克风或扬声器时,音频焦点的处理就很重要。比如用户正在听音乐,你的 App 突然开始语音播报,音乐没停,两个声音混在一起。正确的做法是在播报前请求音频焦点,播报完释放。

val audioManager = getSystemService(AUDIO_SERVICE) as AudioManager val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT) .setOnAudioFocusChangeListener { } .build() audioManager.requestAudioFocus(focusRequest) // 播报结束后 audioManager.abandonAudioFocusRequest(focusRequest)

AUDIOFOCUS_GAIN_TRANSIENT表示短暂占用,适合语音播报这种短时场景。用GAIN会一直占着,影响其他 App,不推荐。

6.3 弱网与离线场景的降级策略

网络不稳定时,在线识别会超时或返回空结果。我的降级策略是:先尝试在线识别,超时后提示用户“网络不太好,要不要试试手动输入”。如果设备支持离线识别,可以在设置里给用户一个开关,让用户自己选。离线识别虽然准确率低一些,但胜在稳定,地铁、电梯里特别有用。

判断网络状态用ConnectivityManager,但要注意 Android 10 之后NetworkInfo被废弃了,要用NetworkCapabilities。这个 API 变化很多人没注意,用了旧 API 在新系统上会拿到错误的状态。

val cm = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager val network = cm.activeNetwork ?: return false val caps = cm.getNetworkCapabilities(network) ?: return false return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)

7. 我在实际项目里攒下的几条经验

第一条,永远不要假设识别结果是准确的。哪怕准确率标称 98%,落到具体场景里,那 2% 可能正好是你最关键的金额或人名。所以关键信息一定要有确认环节,这是产品设计层面的保险,不是技术能完全解决的。

第二条,把语音当成辅助输入,而不是唯一输入。用户在地铁里、开会时、嘈杂环境下,语音根本用不了。所以任何语音功能都要配一个手动输入的入口,让用户有退路。我见过一个 App 只做语音输入,结果用户投诉一片,最后不得不回炉重做。

第三条,日志要打全,但别打敏感内容。调试语音功能时,把识别到的文字、解析结果、错误码都打出来,排查问题会快很多。但注意别把用户的语音内容原样打到线上日志里,涉及隐私。我一般只在 debug 包打完整日志,release 包只打错误码和状态。

第四条,测试要覆盖真机,尤其是低端机。模拟器上的语音能力和真机差别很大,低端机的识别速度、内存占用、发热情况都和旗舰机不一样。我吃过一次亏,在旗舰机上跑得好好的功能,到了千元机上直接卡成幻灯片,原因就是识别回调太频繁,UI 刷新没做节流。

第五条,给用户明确的反馈。语音功能最怕的就是“没反应”。用户按了按钮,到底是在听、在识别、还是失败了,界面上一定要有清晰的状态提示。我一般会做三个状态:正在听(波形动画)、正在识别(转圈)、完成或失败(文字提示)。用户知道发生了什么,就不会反复按按钮,也就避免了很多并发问题。

最后再分享一个小技巧:如果你要做多轮对话,维护一个简单的上下文栈会很有用。比如用户说“记一笔午饭三十”,App 问“记好了,还要记别的吗”,用户说“再来一笔打车二十”,这里的“再来一笔”需要结合上文才能理解。用一个栈存最近几轮的意图和实体,解析时先查栈里有没有可复用的信息,能显著提升多轮场景的体验。这个做法不复杂,但效果立竿见影,值得一试。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询