学习目标
读完本章,你应当能够:
- 理解
TextToSpeech的初始化是异步回调------不能"建完就念";- 看懂本工程"流式朗读 ":按句切分 +
QUEUE_ADD队列模式,让模型边生成边出声;- 说清
UtteranceProgressListener跑在哪个线程、为什么内部必须加锁;- 理解"语言防回退 "这个真实修复:为什么不能每句都
setLanguage();- 会用
ClipboardManager复制文本;- 会用
FileProvider分享文件,并说清为什么不能直接塞EXTRA_TEXT;- 用
ActivityResultContracts拉起系统文件选择器(现代写法)。前置 :ch09、ch10
对应源码(本章会逐个打开):
app/src/main/java/com/example/llama/util/TTSManager.kt(412 行,朗读核心)
app/src/main/java/com/example/llama/util/ChatExporter.kt(导出 + 分享)
app/src/main/java/com/example/llama/MainActivity.kt(ActivityResult 文件选择器)
app/src/main/java/com/example/llama/ui/MainScreenState.kt:719-731(剪贴板)
🧭 读本章前,请先确认前置章节(缺一不可,缺了会卡在哪) :
- ch09(状态容器) :本章的剪贴板、文件选择器回调最终都进
MainScreenState,
不知道它"只管数据和逻辑、UI 只渲染",就看不懂为什么复制/分享的方法都写在那里。- ch10(资源收尾) :TTS 是本章重点,而 TTS 的"初始化闸门、加锁、finally 复位、
取消定时任务、daemon 线程"全是 ch10 收尾主题的延续。没学 ch10,12.4、12.5 会很懵。需要的基础(具体到概念级别) :
- 知道"进程里可以有多条线程 "(读 附录 A.6.3 )------
本章的核心之一就是"回调跑在另一个线程,不是你的线程";- 知道"跨进程通信 Binder"这个词即可(附录 A.6.4,1MB 上限那里会用到);
- 听过"异步 / 回调 "这两个词(本章 12.1 第一次用时会从零解释,不必提前懂)。
(以上基础够用了;本章术语第一次出现时都会解释。)本章会出现的生词 :TTS(text-to-speech,文字转语音) 、异步(async) 、
回调(callback) 、OnInitListener、队列模式(QUEUE_ADD/QUEUE_FLUSH)、
UtteranceProgressListener、Binder 线程 、锁(lock) 、@Volatile、
daemon 线程、ClipboardManager、FileProvider、ActivityResultContracts、
Uri 、MIME 类型、作用域存储。Android 组件查 附录 B.3 。
以上生词不必提前背,读到哪个回来查即可。
(本章四件事互相独立,可分开读、分开练。)读法建议 :本章讲四件互相独立 的系统能力
(TTS / 剪贴板 / 分享 / 文件选择器),可以分开读、分开做 。
其中 TTS 最难 (异步初始化 + 多线程 + 队列),其余三件比较直白。
本章会给出多处真实报错原文 (如Failed to find configured root),
遇到报错时对照着看。
本章导读前面你一直在 App 内部写代码。这一章要走出 App ,用系统提供的能力:
让手机说话、复制文本、把文件分享给别的 App、让用户选个文件。
这些"系统能力"有个共同特点,也是新手最容易栽跟头的地方:
特点 意味着什么 初始化往往异步 构造完对象不能立刻用,要等回调 回调不在你的线程 TTS 的进度回调跑在 Binder 线程,改状态要加锁 依赖外部 App 用户可能没装 TTS 引擎、没装能接收分享的 App 有配额/上限 跨进程传数据有 ~1MB 的 Binder 上限 💡 类比:打电话给各个服务部门
- TTS = 打电话请"播音员"来念稿。你拨号(构造对象)之后要等对方接起来 (
onInit回调),
不能一拨号就开始念。而且对方回电时用的是总机线路(Binder 线程),不是你的座机。- 剪贴板 = 公共白板,谁都能写、谁都能读。
- 分享 = 把文件递给隔壁办公室------但传话窗口只有 1MB 宽 ,
厚文件只能给"取件单"(content://URI)。- 文件选择器 = 请"档案室"帮你找一份文件,它把取件单 (Uri)给你,
而不是把文件本身搬过来。本章的核心:和打交道的对象不在你的进程里,所以"异步"和"跨进程"是默认的。
12.1 TTS:初始化是异步回调
🧭 从零开场白:什么叫"系统能力"?为什么不能自己写?
到目前为止,你写的代码都在"自己家里"------布局、状态、协程、网络,都是 App 内部的事。
但手机上有很多东西你碰不到、也不该碰 :喇叭怎么发声、屏幕上怎么复制文字、另一个 App 怎么拿到你的文件------
这些硬件和服务归 Android 系统 统一管,系统就是个"大管家"。
你写的 App 不能直接对着喇叭喊、不能直接翻别人的文件。想让喇叭说话、想用剪贴板、想把文件发给别的 App,
都必须请大管家帮忙 。系统把这些"帮忙的入口"包装成一个个接口 (API),你调用接口,
大管家再去调度真正干活的硬件和驱动。
🏨 类比:你住酒店
- 你不能自己冲进厨房炒菜,也不能自己去锅炉房烧开水;
- 你只能打电话给前台(= 调用系统接口),说"我要一份炒饭";
- 前台记下需求,安排厨师(= 硬件驱动 / 系统服务)去做,做好了送到你房间(= 回调把结果还给你);
- 你甚至不知道厨师是谁、厨房在哪------你只跟前台打交道。
本章四件事------TTS 朗读、剪贴板、文件分享、文件选择------全是这个模式:
你提需求 → 系统大管家去做 → 做完回调通知你。 记住这个直觉,下面所有"异步、回调、跨进程"的怪现象就都好懂了。
⚠️ 这个类比在一处不精确:酒店前台通常当场应答,而系统服务经常是异步的(12.1 马上讲)。
🧩 先补三个词(本章全篇都在用):
- TTS(Text-To-Speech,文字转语音) :把文字念出来的手机功能。
本工程用它把 AI 的回答读给用户听(开车时看不了屏幕)。- 异步(asynchronous) :你发出一个请求,不马上等结果 ,先去干别的;
事情做完了,系统回头通知你 。生活类比:你在餐厅点完菜(请求),
不站在灶台前等,先回去聊天;菜好了服务员喊你(通知)。- 回调(callback) :你提前"留个电话号"(把一个函数交给系统),
事情一完成,系统自动打这个电话 、调你的函数。
本章的onInit(初始化好了)、onDone(这句念完了)都是回调。⚠️ 异步最大的新手坑 :你发出请求的那一行,事情还没做完 。
所以构造完
TextToSpeech立刻就念,大概率没声音------因为它还在后台"备菜"。
12.1.1 反直觉的事实
这是什么? TextToSpeech 是 Android 系统提供的"文字转语音"服务------你给它一段文字,它用系统自带的引擎把字念出来。它不是 你 new 一个就能用的普通对象,而是要和系统后台的 TTS 引擎"接上线"(这就是 12.1 开头说的"打电话给前台")。
为什么要异步初始化? 接线上要做的事不少:检查系统装了哪些 TTS 引擎、加载语音模型文件、选中默认声音------这些动辄几百毫秒到几秒。如果你"同步"地死等,界面就卡住转圈 (ANR 的温床)。所以系统选择了回调:TextToSpeech(context, this) 这一行立刻返回 ,引擎在后台慢慢准备;准备好了,系统自动调你的 onInit()------等于"你先干别的,好了我叫你"。
📝 最小示例(自己跑通用,非工程代码)
下面这段是最少最少的骨架------创建、等回调、说一句话:
kotlin// 📝 示例(需自行验证) class MainActivity : AppCompatActivity(), TextToSpeech.OnInitListener { private var tts: TextToSpeech? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) tts = TextToSpeech(this, this) // ① 创建,把 this 当回调传进去;这一行立刻返回 } override fun onInit(status: Int) { // ② 引擎准备好后,系统自动调它 if (status != TextToSpeech.SUCCESS) { // ③ 初始化失败:提示用户,别崩(见下方🛟说明) findViewById<TextView>(R.id.status).text = "这台手机没有可用的 TTS 引擎" return } tts?.language = Locale.CHINA // ④ 现在才能念;speak 本身也是异步的,塞完就返回 tts?.speak("你好", TextToSpeech.QUEUE_FLUSH, null, "greet") } override fun onDestroy() { tts?.shutdown(); super.onDestroy() } }对照一下:① 到 ② 之间隔着几百毫秒到几秒,这期间任何
speak都是无效的 ------这就是为什么"构造完立刻念"没声音。④ 那行
speak本身也是异步 的:它把句子塞进引擎队列就返回,真正出声发生在后台,不会卡住你。
你可能以为这样就能朗读:
kotlin
val tts = TextToSpeech(context, listener)
tts.speak("你好", ...) // ❌ 大概率没声音
为什么? 因为 TextToSpeech 需要加载语音引擎、连接 TTS 服务------这些都是异步的 。
构造返回时,引擎可能还没准备好。
12.1.2 正确的做法:等 onInit 回调
✅ 取自本工程、已编译验证 ------ TTSManager.kt:24-30, 88-96
kotlin
class TTSManager(context: Context) : TextToSpeech.OnInitListener {
private val tts: TextToSpeech = TextToSpeech(context, this) // :26 把 this 当回调传进去
private val lock = ReentrantLock()
@Volatile
private var isInitialized = false // :30
override fun onInit(status: Int) {
if (status != TextToSpeech.SUCCESS) {
Log.e(TAG, "TTS initialization failed with status: $status")
isInitialized = false
return
}
isInitialized = true
Log.d(TAG, "TTS initialized successfully")
...
}
三个要点:
| 要点 | 说明 |
|---|---|
类实现 OnInitListener |
把自己当回调传进 TextToSpeech(context, this) |
isInitialized 标志 |
初始化完成的"闸门" |
@Volatile |
它会被多线程读写(主线程调 speak,Binder 线程回调 onInit)------ch10 讲过必须加 |
🧩
@Volatile是什么?(一句话) :它是给一个变量贴的标签,意思是"这个变量会被多个线程同时读写,请别偷偷把它优化缓存起来、每次都老老实实读内存 "。
不加它,一个线程把
isInitialized改成true,另一个线程可能还拿着旧的缓存值false,就会出现"明明初始化好了,代码却说还没好"的诡异 bug。
记住:凡是被多线程读写的布尔开关,加上
@Volatile。
🧵onInit回调跑在哪个线程? 主线程(UI 线程)。所以上面最小示例里直接在
onInit里改findViewById的文字是安全的------只有主线程才能碰 UI 。但要把三件事分清楚:
onInit("初始化好了")→ 主线程,可以直接改界面;speak()把句子交给引擎后立刻返回 ,念不念、什么时候念完由引擎在后台干,不阻塞主线程;- 而"这句念完了"的
onDone(12.1.4 注册的那个)→ 不在主线程,在系统的 Binder 线程,12.4 专门讲它。一句话:初始化回调在主线程,朗读进度回调在 Binder 线程。 别记反了。
🛟 为什么初始化失败必须兜底?onInit的status可能不是SUCCESS------最常见的原因就是这台设备根本没有 TTS 引擎 (很多 AVD 模拟器就是空的)。
这时如果不判断
status,后面照常speak,轻则没声音,重则崩溃。工程里的做法(
TTSManager.kt:28-29)很朴素:记日志、把isInitialized留成false,所有对外方法第一行就是
if (!isInitialized) return------没有引擎就安静地拒绝朗读,而不是把崩溃甩给用户 。真机上几乎遇不到,模拟器上你大概率会亲手遇到一次。
12.1.3 闸门怎么用
所有对外方法第一件事 就是检查它(✅ TTSManager.kt:130-134):
kotlin
fun speakNew(text: String, locale: Locale? = null) {
if (!isInitialized || isShutdown) {
Log.w(TAG, "TTS unavailable, drop text (initialized=$isInitialized)")
return
}
beginStream(:159-162)、appendToStream(:178)也一样。
💡 这个模式叫"守卫子句(guard clause)" :
不满足条件就直接 return,避免后面一大堆代码都要嵌套在
if里。本工程用了同一个判断
!isInitialized || isShutdown------两个条件都要看 ,因为"还没初始化完"和"已经关闭了"都不能朗读。
12.1.4 注册进度监听
初始化成功后,注册"朗读进度"回调(✅ TTSManager.kt:97-109):
kotlin
tts.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) {
Log.d(TAG, "TTS onStart: $utteranceId")
}
override fun onDone(utteranceId: String?) = onUtteranceFinished(utteranceId)
@Suppress("OVERRIDE_DEPRECATION")
override fun onError(utteranceId: String?) = onUtteranceFinished(utteranceId)
override fun onError(utteranceId: String?, errorCode: Int) =
onUtteranceFinished(utteranceId)
})
注意 onDone 和 两个 onError 重载都指向同一个 onUtteranceFinished------
"读完"和"出错"都走同一套收尾逻辑,保证队列一定会被推动下去。
📌 如果漏了
onError的处理,一旦某句朗读失败,队列就永远卡住------用户听到一半就没声了,且不会再继续。
12.2 语言防回退:一个真实的"声音突变"bug
12.2.1 注释里写着的根因
✅ 取自本工程 ------ TTSManager.kt:58-64
kotlin
/**
* 最近一次成功应用到引擎的语言;null 表示从未强制设置(保持引擎默认声音)。
*
* 此前每朗读一句都无条件 setLanguage(),部分引擎会因此重选声音;语言数据缺失时
* 还会静默回退英文------这两点是"朗读声音中途变成另一个"的根因。
*/
private var appliedLocale: Locale? = null
现象:用户反馈"朗读到一半,声音突然变成另一个人"。
两个根因:
- 每句都调
setLanguage()→ 部分引擎会重新选择声音; - 语言数据缺失时 → 引擎静默回退成英文(中文用英文引擎念,非常诡异)。
12.2.2 修复:只在语言真的变了才设置
✅ 取自本工程、已编译验证 ------ TTSManager.kt:310-346
kotlin
private fun applyLanguageIfNeeded(locale: Locale?) {
when {
locale == null && appliedLocale == null -> {
// 从未强制设置:保持引擎默认声音
}
locale == null -> {
// 此前强制设置过语言,现回到"跟随默认":恢复设备默认语言
val result = tts.setLanguage(Locale.getDefault())
if (result != TextToSpeech.LANG_MISSING_DATA && result != TextToSpeech.LANG_NOT_SUPPORTED) {
appliedLocale = null
}
}
locale == appliedLocale -> {
// 语言未变:不重复设置,避免引擎重选声音
}
else -> {
val result = tts.setLanguage(locale)
if (result == TextToSpeech.LANG_MISSING_DATA || result == TextToSpeech.LANG_NOT_SUPPORTED) {
Log.w(TAG, "Language $locale not supported (result=$result), keep current voice")
} else {
appliedLocale = locale
}
}
}
}
四个分支:
| 情况 | 行为 | 为什么 |
|---|---|---|
locale == null 且从未设置过 |
完全不调 setLanguage |
保持引擎/系统默认声音(用户没改设置时的预期) |
locale == null 但设置过 |
恢复设备默认语言 | 用户从"指定语言"切回"跟随默认" |
locale == appliedLocale |
不重复设置 | ← 核心修复:避免引擎重选声音 |
| 语言真的变了 | setLanguage(locale) |
但不支持时只告警、保持当前声音 |
📌 最后那个"不支持时不回退"是关键:
kotlinif (result == LANG_MISSING_DATA || result == LANG_NOT_SUPPORTED) { Log.w(TAG, "Language $locale not supported, keep current voice") // 只告警 } else { appliedLocale = locale // 成功了才记录 }语言不支持时保持当前声音 ,绝不静默切到英文。
而且
appliedLocale只在成功时才更新------下次还会再试一次。
12.2.3 这个修复的通用价值
💡 "只在真正变化时才做某件事" 是一个高频优化模式。
本工程里同类例子:
- 这里:只在语言变了才
setLanguage;- ch07:
MessageList三个 key 只在相关值变了才重启动效;- ch11:
addModel只在路径不同时才替换(indexOfFirst { it.path == ... })。看到"每帧/每次都无条件执行"的代码,先问一句:它真的需要每次都做吗?
12.3 流式朗读:边生成边出声
12.3.1 为什么需要"流式"
这是什么? 大模型回答问题是一个字一个字往外蹦 的(流式输出)。
如果"等整段回答全写完再朗读",用户得盯着屏幕干等十几秒才听到声音;
"流式朗读"就是生成一句、朗读一句 ,让用户边看边听。本工程(F5)正是这么做的。
为什么需要队列? 模型吐字的速度和 TTS 念字的速度不一样 :模型可能一秒吐十几个字,
而人正常语速一秒念三四个字。如果来一个字就 speak 一次,引擎会被打爆、句子也碎成单字。
所以要攒到"完整一句"再丢给 TTS;而攒下来的句子要排队 ------TTS 一次只能念一句,
念完一句再拿下一句,谁也不许插队。
🧋 类比:排队买奶茶
取餐口一次只能叫一个号。前面的人还没做好,后面点单的人乖乖在队尾等着 ;
做好一个、叫一个号、下一个补上。TTS 的句子队列就是这个:
新生成的句子排到队尾,
onDone(这句念完)一响,就从队首拿下一句接着念。
📝 最小句子队列(非工程代码,帮你建立直觉)
kotlin// 📝 示例(需自行验证) val queue = ArrayDeque<String>() // ① 待朗读句子队列 var speaking = false // ② 当前是否正在念 fun onModelSentenceReady(sentence: String) { // 模型吐出完整一句时 queue.addLast(sentence) // ③ 排到队尾 pump() // ④ 试着启动朗读 } fun pump() { if (speaking) return // 正在念:排队就行,别插队 val next = queue.removeFirstOrNull() ?: return speaking = true tts.speak(next, TextToSpeech.QUEUE_ADD, null, "u") // ⑤ 念它 } // TTS 念完一句的回调里: fun onUtteranceDone() { speaking = false pump() // ⑥ 念完就叫下一个号 }这个 6 步骨架就是工程里
textQueue+processNextInQueue+onUtteranceFinished的"平民版"。工程代码只是多了线程锁、多了"句末切分"、多了 500ms flush 兜底------排队模型一模一样。
12.3.2 三个方法
✅ 取自本工程、已编译验证 ------ TTSManager.kt:150-198
kotlin
// region 流式朗读(F5)
fun beginStream(locale: Locale? = null) {
if (!isInitialized || isShutdown) { ...; return }
lock.withLock {
stopInternal() // 内部会复位 isStreaming,流式标志须在其后再置 true
activeLocale = locale
// 流式必须用 QUEUE_ADD:否则每来一个新句子都会 QUEUE_FLUSH 掉上一条,听感是不断被打断
activeQueueMode = TextToSpeech.QUEUE_ADD
isStreaming = true
}
}
fun appendToStream(chunk: String) {
if (!isInitialized || isShutdown || chunk.isEmpty()) return
lock.withLock {
// 在锁内检查 isStreaming,避免与 endStream() 的竞态条件
if (!isStreaming) return
textQueue.add(chunk)
processNextInQueue(activeLocale, activeQueueMode)
}
}
/** 结束流式会话:冲刷最后一段不足一句的残留文本并复位流式标志 */
fun endStream() {
lock.withLock {
// 会话已被 stop() 终止(用户点停/新朗读接管)时不再补读残留,避免"停了又冒声"
if (!isStreaming) return
isStreaming = false
if (accumulatedText.isNotBlank() && !isShutdown && isInitialized) {
speakText(accumulatedText.toString().trim(), activeLocale, activeQueueMode)
accumulatedText.clear()
}
}
}
12.3.3 两个队列模式的区别(关键)
| 模式 | 行为 | 用在哪 |
|---|---|---|
QUEUE_FLUSH |
清空队列、立即念新的(打断式) | 一次性朗读 speakNew(:142) |
QUEUE_ADD |
排队,等前面的念完再念(顺序播放) | 流式 beginStream(:167) |
注释(:166)解释得非常清楚:
流式必须用 QUEUE_ADD:否则每来一个新句子都会 QUEUE_FLUSH 掉上一条,
听感是不断被打断。
💡 想象一下:如果用 FLUSH,每生成一个新句子就把上一句打断------用户听到的是"今天天...""今天天气很...""今天天气很好..."不断重启,根本听不清 。
用 ADD 则是"今天天气很好。适合出门。"一句接一句,自然。
12.3.4 按句切分
不是每来一个 token 就念(那样会碎成单字),而是累积到句末标点才念。
为什么要"按句切"?模型吐出来的是一长串没有停顿的连续文字 ,如果把一大段一次性丢给 TTS:
一是 TTS 读长段容易卡顿、断句位置不对 ,把"你好,我是一个助手"念得像"你好我是/一个助手";
二是没有句末停顿,听着像机关枪,不自然。按句号/问号/感叹号切成一句一句,
引擎才知道在哪喘气、在哪换气------停顿就是标点给的。
句末标点集合(✅ TTSManager.kt:81-82):
kotlin
/** 句末判定用的标点集合 */
private val SENTENCE_ENDINGS = charArrayOf('.', '!', '?', '。', '!', '?', '\n', ';', ';')
注意它同时包含中英文标点------本项目要朗读中英混排的文本,这点很重要。
还有个兜底冲刷 (FLUSH_DELAY_MS = 500,:79):
如果迟迟没等到句末标点(比如模型输出一大段没有句号),
500ms 后强制把累积的文本念出来,避免一直憋着不出声。
12.3.5 调用顺序(配合 ch08)
在 MainScreenState.sendMessage 里:
| 时机 | 调用 |
|---|---|
生成开始(:806) |
ttsManager.beginStream(ttsLocale) |
每次节流刷新(:849) |
ttsManager.appendToStream(visible.substring(ttsFedLength)) |
生成结束(:873) |
ttsManager.endStream() |
出错/取消(:824、:880、:885) |
ttsManager.endStream() |
注意 appendToStream 只传增量 (visible.substring(ttsFedLength)),
不是全文------ch08 讲过,TTS 重复念已念过的内容会很奇怪。
12.4 线程模型:回调在 Binder 线程,必须加锁
12.4.1 文件头就写了
✅ 取自本工程 ------ TTSManager.kt:20-23
kotlin
* 线程模型:所有内部状态都由 [lock] 保护,TTS 回调运行在 Binder 线程,
* 而 speak/stop 可能来自任意协程线程。
kotlin
private val lock = ReentrantLock() // :27
12.4.2 为什么危险
🧩 先补一个词:什么叫"锁(lock)"?
想象一个公共卫生间只有一把钥匙 。谁要用,先拿到钥匙、开门进去、从里面反锁;
里面的人出来才把钥匙挂回去,下一个人才能进。同一时刻只能一个人用 。
程序里的锁也是这个意思:两个线程要改同一份数据时,先抢同一把"钥匙"(
lock.withLock { }),谁拿到谁进去改,改完放钥匙,另一个再进。这样两个人就不会同时改同一份数据、互相踩脚。
下一节的问题,就是因为"至少两类线程会同时碰同一份状态"。
TTSManager 的状态(isSpeaking、textQueue、currentUtteranceId......)
会被至少两类线程同时访问:
| 线程 | 做什么 |
|---|---|
| Binder 线程(系统回调) | onUtteranceFinished() ------ 一句念完,改状态、推队列 |
| 任意协程线程 | speakNew / appendToStream / stop ------ 用户操作 |
如果不加锁,可能出现:
- 两个线程同时
textQueue.add()→ 列表损坏; - 一个在清空队列,另一个在读 → 读到不一致状态;
isSpeaking读写撕裂 → 队列卡死。
12.4.3 本工程怎么加
所有 涉及状态的公开方法都用 lock.withLock { } 包住:
✅ 取自本工程 ------ TTSManager.kt:112-127(回调里也加锁!)
kotlin
/** 朗读结束或出错后统一收尾:复位状态并推动队列继续 */
private fun onUtteranceFinished(utteranceId: String?) {
Log.d(TAG, "TTS finished: $utteranceId")
lock.withLock {
// QUEUE_ADD 下排队中的句子只有"最后一条"的 id 与 currentUtteranceId 一致:
// 前面句子完成时提前返回,保持 isSpeaking,由最后一条收尾并推动队列
if (utteranceId != currentUtteranceId) return
isSpeaking = false
currentUtteranceId = ""
isProcessingQueue = false
if (textQueue.isNotEmpty() || accumulatedText.isNotEmpty()) {
processNextInQueue(activeLocale, activeQueueMode)
}
}
}
📌 注意那个
if (utteranceId != currentUtteranceId) return:在
QUEUE_ADD模式下,多句话排队时,每一句念完都会回调onDone。但只有最后一句 的 id 等于
currentUtteranceId。前面的句子直接 return(保持
isSpeaking),由最后一句负责收尾并推动队列。
这是一个很细节但很关键的判断------没有它,第一句念完就会把
isSpeaking置 false,导致后续句子被认为是"空闲",队列推进逻辑全乱。
12.4.4 ReentrantLock vs synchronized
ReentrantLock |
synchronized |
|
|---|---|---|
| 写法 | lock.withLock { }(Kotlin 扩展) |
synchronized(obj) { } |
| 可重入 | ✅ | ✅ |
| 可中断/超时 | ✅(tryLock) |
❌ |
| 本工程 | 用它(:27) |
未用 |
本项目用 ReentrantLock + Kotlin 的 withLock 扩展,写法简洁且功能更全。
12.5 停止:别让"停了又冒声"
12.5.1 stopInternal 的 finally
✅ 取自本工程 ------ TTSManager.kt:206-223
kotlin
/** 调用方必须已持有 [lock] */
private fun stopInternal() {
try {
cancelPendingFlush()
tts.stop()
} catch (e: Exception) {
Log.e(TAG, "Error stopping TTS", e)
} finally {
isSpeaking = false
isProcessingQueue = false
currentUtteranceId = ""
textQueue.clear()
accumulatedText.clear()
// F5:stop() 同时终止流式会话------用户点停后 appendToStream 变为 no-op,
// 不会再把后续生成的文本重新排队朗读
isStreaming = false
}
}
finally 保证状态一定被复位(ch10 讲过这个模式)。
特别是 isStreaming = false(:221)------
用户点了停止后,后续 appendToStream 会因为 if (!isStreaming) return(:181)变成 no-op ,
不会再把新生成的文本重新排队。
⚠️ 如果漏了这一行,会出现一个很烦人的 bug:
用户点了"停止朗读",但因为模型还在生成,
appendToStream继续被调用,于是声音又冒出来了------"停了又响"。
12.5.2 cancelPendingFlush:定时器也要取消
✅ 取自本工程 ------ TTSManager.kt:55-56, 226-229
kotlin
/** 待执行的 flush 任务;此前只保存了 Runnable,导致 stop() 无法真正取消它 */
private var pendingFlush: ScheduledFuture<*>? = null
/** 取消尚未执行的 flush 任务,防止 stop 之后又冒出一段朗读 */
private fun cancelPendingFlush() {
pendingFlush?.cancel(false)
pendingFlush = null
}
又一个真实修复 (注释 :55 写明):
此前只保存了
Runnable,导致stop()无法真正取消它
如果只存 Runnable,你没有句柄 去取消这个定时任务------
它会在 500ms 后照常触发,"停了又冒声"的另一个来源 。
改成保存 ScheduledFuture<*> 后,才能 cancel()。
📌 凡是"延迟执行"的任务,都要保存可取消的句柄。
这和第 10 章讲的
releaseJob?.cancel()是同一类问题。
12.5.3 daemon 线程
✅ 取自本工程 ------ TTSManager.kt:66-75
kotlin
/**
* 延迟朗读(句末未到时的兜底冲刷)用的单线程调度器。
*
* 用 daemon 线程:默认线程工厂创建的是非 daemon 线程,shutdown 后若还有残留任务
* 会拖住进程退出。
*/
private val executor: ScheduledExecutorService =
Executors.newSingleThreadScheduledExecutor { r ->
Thread(r, "tts-flush").apply { isDaemon = true }
}
💡 什么是 daemon 线程?
JVM 会等所有非 daemon 线程 结束后才退出。
如果你的线程池用了非 daemon 线程且没关干净,进程就退不出去 (手机上表现为 App 残留)。
设为 daemon 后,JVM 退出时不会等它们。
这是"资源收尾"的一个细节(ch10 主题),值得记住。
12.6 剪贴板
12.6.1 这是什么、为什么需要
这是什么? 剪贴板(Clipboard)是系统提供的一块"临时存放文字的地方 "。
你在任意 App 里点"复制",文字就被放进这块公共白板;到另一个 App 点"粘贴",就是从白板上把字拿出来。
为什么必须走它? App 之间不能直接共享内存 ------你 App 里一个字符串变量,别的 App 根本看不见。
想把 AI 的回答"递"到微信、备忘录、浏览器里,唯一合法的通道就是系统剪贴板:你写进去,别的 App 自己来读。
🌐 剪贴板是全局的 :它是系统级 的白板,所有 App 共用。你在这个 App 复制的文字,切到微信就能粘贴。
这很方便,但也意味着别往剪贴板里塞密码、身份证号------用户切到别的 App 随手一粘贴,敏感信息就漏出去了。
📝 最小示例(复制 + 读取)
kotlin// 📝 示例(需自行验证) val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager // 复制(写进白板) clipboard.setPrimaryClip(ClipData.newPlainText("label", "你要复制的文字")) // 读取(从白板拿出来) val pasted = clipboard.primaryClip?.getItemAt(0)?.text?.toString()注意
newPlainText的第一个参数label只是"这是什么内容"的备注(某些剪贴板 UI 会显示),第二个参数才是真正的文字。读取时
primaryClip可能为 null(白板是空的),一定要判空。
✅ 取自本工程、已编译验证 ------ MainScreenState.kt:718-731
kotlin
/** 复制单条消息内容到剪贴板 */
fun copyMessage(content: String) {
if (content.isBlank()) {
showToast("内容为空")
return
}
val clipboard = application.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
if (clipboard == null) {
showToast("复制失败")
return
}
clipboard.setPrimaryClip(ClipData.newPlainText("chat_message", content))
showToast("已复制到剪贴板")
}
12.6.2 三个要点
| 要点 | 说明 |
|---|---|
| 不需要任何权限 | 复制是安全的(用户主动触发),Android 不要求权限 |
as? 安全转换 |
拿不到 ClipboardManager 时返回 null,不会崩 |
ClipData.newPlainText(label, text) |
label 是给用户看的描述(某些剪贴板 UI 会显示) |
📌 注意两次守卫 + Toast :内容为空、剪贴板不可用都有提示。
不要让用户点了按钮却毫无反馈------这是最基础的 UX。
12.6.3 复制全部
✅ 取自本工程 ------ MainScreenState.kt:733-739
kotlin
/** 复制当前会话的全部内容(Markdown 格式,与导出共用排版) */
fun copyAllMessages() {
val session = sessions.firstOrNull { it.id == currentSessionId }
if (session == null || session.messages.isEmpty()) {
showToast("没有可复制的对话")
return
}
注意注释里的"与导出共用排版 "------
复用了 ChatExporter.formatExportContent(ch11 讲过的纯函数),
所以"复制出来的内容"和"导出成文件的内容"格式完全一致。
12.7 分享:为什么必须走 FileProvider
12.7.1 这是什么:FileProvider 与那 1MB 天花板
这是什么? 从 Android 7.0 起,App 不能直接把自己私有目录的文件路径(file://...)交给别的 App 。
系统要求你通过一个叫 FileProvider 的组件,把文件"换"成一张 content://... 的临时通行证 ------
别的 App 凭这张通行证、在授权有效期内,去读你允许的那一个文件。
🏦 类比:你家的保险箱
- 你的导出文件存在 App 私有目录,像你家保险箱,别人没有钥匙;
- 直接把钥匙(
file://路径)塞给邻居 → 系统在 7.0 以后禁止 这么干,传出去就抛FileUriExposedException;- 正确做法:找物业(FileProvider)开一张临时通行证 (
content://URI),
上面写明"只允许取这一件物品、本次交易有效",邻居凭牌拿货,牌一过期就作废。通行证既让邻居拿到了他要的东西,又不会暴露你整个保险箱------这就是"最小授权"。
为什么需要? 三层原因叠在一起:
- 权限:私有目录别的 App 本来就没权限读,你给路径它也打不开;
- 安全:直接暴露路径等于"钥匙上门",别的 App 可能顺着猜到你别的文件路径;
- 版本 :Android 7+ 硬性禁止
file://跨 App 传递,违反直接崩。
还有个 1MB 天花板 ------这正是"为什么不能直接塞 EXTRA_TEXT"的直接原因:
跨进程传数据走 Binder,一次最多约 1MB 。把整段长对话塞进 Intent.EXTRA_TEXT 直接分享,
超过就抛 TransactionTooLargeException。所以长对话不能把文本本身递过去 ,只能递一张"取件单"(URI),
让对方按需来读文件。
ChatExporter 的注释(✅ ChatExporter.kt:36-41)说得很清楚:
长对话会超出 Binder 事务上限(约 1MB),直接
ACTION_SEND+EXTRA_TEXT会分享失败甚至崩溃 ,所以一律走"文件 +
content://URI"。
kotlin
// ❌ 长对话这样分享会崩
intent.putExtra(Intent.EXTRA_TEXT, veryLongConversation)
// 超过 ~1MB → android.os.TransactionTooLargeException
📝 最小对比示例(非工程代码)
kotlin// 📝 示例(需自行验证) // ❌ 旧写法 / 必然崩:直接 file:// 路径 val badUri = Uri.fromFile(File(context.cacheDir, "exports/x.md")) val bad = Intent(Intent.ACTION_SEND).apply { type = "text/markdown" putExtra(Intent.EXTRA_STREAM, badUri) // Android 7+ 直接 FileUriExposedException } // ✅ 新写法:FileProvider 换 content:// 通行证 val goodUri = FileProvider.getUriForFile( context, "${context.packageName}.fileprovider", File(context.cacheDir, "exports/x.md") ) val good = Intent(Intent.ACTION_SEND).apply { type = "text/markdown" putExtra(Intent.EXTRA_STREAM, goodUri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) // 临时授权,少了它对方读不了 }两者只差几行,语义却天差地别:一个是"把钥匙给别人",一个是"开张临时通行证"。
工程里的
createShareIntent(12.7.2)就是把右边这段写成了函数。
12.7.2 解法:文件 + content:// URI
✅ 取自本工程 ------ ChatExporter.kt:49-66(简化)
kotlin
fun createShareIntent(session: ChatSession): Intent? = runCatching {
val content = formatExportContent(session.title, session.messages) // ① 生成 Markdown
val file = writeToExportsDir(content) // ② 写文件
val uri = FileProvider.getUriForFile( // ③ 换取 content:// URI
context, "${context.packageName}.fileprovider", file
)
Intent(Intent.ACTION_SEND).apply { // ④ 只传 URI
type = "text/markdown"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) // 临时授权
}
}.onFailure { Log.e(TAG, "Create share intent failed", it) }.getOrNull()
四步:
Markdown 文本 → 写入 cache/exports/xxx.md → FileProvider 换 content:// URI → 分享 URI
URI 是很短的字符串 ,传给别的 App 毫无压力。
对方拿到 URI 后通过 ContentResolver 按需读取文件内容------不受 1MB 限制。
12.7.3 配套:Manifest 的 FileProvider(回顾 ch04)
✅ 取自本工程 ------ AndroidManifest.xml:30-38
xml
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
以及声明可分享的目录(✅ res/xml/file_paths.xml):
xml
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="exports" path="exports/" />
</paths>
⚠️ 如果文件不在声明范围内,会抛:
IllegalArgumentException: Failed to find configured root that contains ...(ch04 讲过)
12.7.4 FLAG_GRANT_READ_URI_PERMISSION
这一句不能少:
kotlin
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
它临时授权 接收方读取这个 URI。没有它,对方拿到 URI 也读不了(SecurityException)。
💡 这正是 FileProvider 的价值:既让对方能读,又只授权这一个文件、只在本次有效 ------
比直接把整个私有目录暴露出去安全得多。
12.8 文件选择器:ActivityResult 现代写法
这是什么? Android 10 以后,App 不能自己拿个文件列表去遍历用户整个磁盘 让你挑------
那样既费权限又侵犯隐私。系统提供了一个统一的文件选择器 (学名 Storage Access Framework,存储访问框架):
你发一个请求,系统弹出自己的"文件选择"界面,用户亲手选一个文件,选完系统把这个文件的 Uri 通过回调还给你。
为什么需要它,而不是自己列文件?
- 安全:用户明确知道"我正在把一个文件交给这个 App",而不是你的 App 在背后翻他的相册;
- 少申请一堆权限 :老办法要
READ_EXTERNAL_STORAGE,新办法用户选完你就拿到那一个文件的临时读权限; - 覆盖所有来源:系统选择器背后不只是本地磁盘,还能接网盘------你不用自己写适配。
"弹出选择器 + 接收结果"这一对动作 ,现代写法就是 ActivityResultContracts:
先在 onCreate 里注册 一个 launcher(= 登记"选完文件交给哪个回调"),
需要时调 launch() 把它弹出来,用户选完,回调里拿到 Uri?。
📝 最小示例(注册 + 启动 + 回调)
kotlin// 📝 示例(需自行验证) class PickActivity : AppCompatActivity() { // ① 先在 onCreate 里注册(不能在点击事件里注册!) private val picker = registerForActivityResult( ActivityResultContracts.GetContent() ) { uri: Uri? -> // ② 用户选完(或取消)后回调到这里 if (uri == null) { return@registerForActivityResult // 用户点了"取消":什么都不做,别崩 } // ③ 拿到的是 content:// Uri,用 ContentResolver 打开输入流来读 contentResolver.openInputStream(uri)?.use { stream -> // ...读 stream... } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) findViewById<Button>(R.id.pickBtn).setOnClickListener { picker.launch("*/*") // ④ 用户点按钮时才 launch } } }记四步:注册(onCreate)→ launch(用户触发)→ 回调拿 Uri → ContentResolver 读流 。
工程里的
openFileChooser/importSelectedModel(12.8.2 / 12.8.3)就是这个骨架。
📌 从选择器回来的是content://Uri,不是/sdcard/xxx.txt这样的文件路径。别拿它去
File(uri.path)------在作用域存储下这条路基本走不通,还会崩。正确读法永远是
contentResolver.openInputStream(uri),把它当成"一个能读流的网址"就行。
12.8.1 旧写法 vs 新写法
旧(onActivityResult) |
新(ActivityResultContracts) |
|
|---|---|---|
| 写法 | 重写 onActivityResult,用 requestCode 手动分发 |
注册一个 launcher,回调类型安全 |
| 问题 | requestCode 多了就乱;容易漏 super |
--- |
| 生命周期 | 容易在 Fragment 上出问题 | 必须在 onCreate 注册,自动管理 |
12.8.2 本工程的实现
第一步:声明字段 (✅ MainActivity.kt:40)
kotlin
private lateinit var filePickerLauncher: ActivityResultLauncher<String>
第二步:在 onCreate 里注册 (✅ MainActivity.kt:49-51)
kotlin
filePickerLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
if (uri != null) importSelectedModel(uri)
}
ActivityResultContracts.GetContent() ------ 系统内置的"选择一份内容"契约。
Lambda 的参数就是选中的 Uri?(可能为 null:用户取消了)。
第三步:触发 (✅ MainActivity.kt:81-83)
kotlin
private fun openFileChooser() {
filePickerLauncher.launch("*/*")
}
"*/*" 是 MIME 类型过滤------这里表示"所有类型"。
如果你只想选图片,可以传 "image/*"。
🧩 MIME 类型是什么? 它是给文件类型起的标准小名 ,格式是
大类/小类:
image/png(png 图片)、text/markdown(Markdown 文本)、*/*(星号=通配,什么都要)。这里传给文件选择器的
"*/*",意思就是"用户爱选什么文件都行"。
⚠️ 为什么必须在onCreate注册?
registerForActivityResult会在内部创建一个与生命周期绑定的观察者。如果在
onCreate之外(比如某个点击事件里)调用,会抛:
java.lang.IllegalStateException: LifecycleOwner ... is attempting to register while current state is RESUMED. LifecycleOwners must call register before they are STARTED.记住:注册一次(onCreate),launch 多次。
12.8.3 拿到的是 Uri,不是路径
kotlin
private fun importSelectedModel(uri: Uri) {
mainState.importModel(uri) // ✅ :86-88
}
回调给你的是 Uri ,不是文件路径。
📌 Android 10+ 的"作用域存储":
别的应用给你的文件,你只能拿到
Uri,要通过
ContentResolver读取(本工程在ModelImporter里用
context.contentResolver.openInputStream(uri))。不要试图把 Uri 转成文件路径------大概率拿不到,还会崩。
12.9 常见错误与排查
| 现象 / 报错 | 原因 | 解决 |
|---|---|---|
| TTS 没声音 | 初始化没完成就 speak | 等 onInit,用 isInitialized 闸门(12.1) |
| 朗读声音中途变成另一个 | 每句都 setLanguage / 不支持时回退 |
只在语言变了才设置,不支持时保持(12.2) |
| 流式朗读不断被打断 | 用了 QUEUE_FLUSH |
改用 QUEUE_ADD(12.3.3) |
| 点停止后又冒出声音 | 没复位 isStreaming / 没取消定时 flush |
stopInternal 的 finally + cancelPendingFlush(12.5) |
| 队列卡住不再朗读 | onError 没处理 |
onError 也调 onUtteranceFinished(12.1.4) |
| 状态偶尔错乱/崩溃 | 回调在 Binder 线程,没加锁 | 所有状态访问包 lock.withLock(12.4) |
| App 退不干净 | 线程池用了非 daemon 线程 | isDaemon = true(12.5.3) |
TransactionTooLargeException |
Intent 塞了 >1MB 文本 | 走文件 + content://(12.7) |
FileUriExposedException |
传了 file:// URI |
用 FileProvider(12.7) |
Failed to find configured root |
文件不在 file_paths.xml 范围 |
补声明(12.7.3) |
| 分享后对方读不了文件 | 少了授权 flag | FLAG_GRANT_READ_URI_PERMISSION(12.7.4) |
attempting to register while current state is RESUMED |
launcher 没在 onCreate 注册 |
移到 onCreate(12.8.2) |
| 选文件后拿到路径为空 | Android 10+ 作用域存储 | 用 Uri + ContentResolver(12.8.3) |
12.10 动手验证:朗读中英混排
12.10.1 步骤
- 加载模型,开启 TTS 开关(顶栏的 Switch)。
- 发送一条中英混排 的消息,例如: 请用中文和 English 混合回答,介绍一下 Transformer 是什么?
- 观察:
- 是否在模型还在吐字时就开始朗读(流式生效);
- 中英文是否用同一个声音连续念下来(没有中途变声);
- 句末(
。/.)处是否有自然停顿。
12.10.2 加日志观察(可选)
在 speakText(TTSManager.kt:354)里加一行:
kotlin
Log.d("TTS", "speak: queueMode=$queueMode, len=${cleanText.length}, text=$cleanText")
然后过滤 TTS 看:
TTS: speak: queueMode=1, len=12, text=今天天气很好。
TTS: speak: queueMode=1, len=8, text=适合出门。
queueMode=1是QUEUE_ADD,0是QUEUE_FLUSH------流式下应该一直是 1;- 能看到按句切分的效果(每句一次 speak,而不是每字一次)。
12.10.3 对照实验:改成 QUEUE_FLUSH
把 beginStream 的 activeQueueMode = TextToSpeech.QUEUE_ADD 改成 QUEUE_FLUSH,
再跑一次------你会听到不断被打断 的效果。
这能让你直观理解两个队列模式的差别(做完记得改回来)。
12.10.4 如果没声音,按顺序排查
- 顶栏 TTS 开关打开了吗? 没开的话根本不会调朗读,先打开。
- 手机本身有没有声音、媒体音量是不是零 :按音量键加一下;有些模拟器根本没有 TTS 引擎 ,
怎么试都没声------这种情况请用真机,模拟器上能验证"日志里有没有走 speak",但听不到声音是正常的。 - AI 回复里真的有中文吗? 让模型先回复一句明确的中文(比如"你好,我是助手")再观察,
别一上来就测中英混排。 - 看日志 :过滤
TTSManager,如果根本没有TTS initialized successfully,
说明初始化就失败了(设备没装 TTS 引擎或缺语音数据)------这是设备问题,不是本工程代码问题。 - 做了 12.10.3 的对照实验后 ,记得把
QUEUE_FLUSH改回QUEUE_ADD再继续。
12.11 小结
| 概念 | 一句话 | 工程示例 |
|---|---|---|
| TTS 异步初始化 | 构造完不能立刻用,等 onInit |
isInitialized 闸门(:30、:131) |
@Volatile |
跨线程布尔标志必须加 | isInitialized / isShutdown(:29/:33) |
| 守卫子句 | 不满足就 return,避免嵌套 | `if (!isInitialized |
UtteranceProgressListener |
朗读进度回调,跑在 Binder 线程 | onDone/onError → onUtteranceFinished(:102-108) |
| 加锁 | 所有状态访问包 lock.withLock |
:27、:115、:140 等 |
| onError 也要处理 | 否则队列永久卡住 | 两个 onError 都调 onUtteranceFinished |
| 语言防回退 | 只在语言变了才 setLanguage;不支持时保持 | applyLanguageIfNeeded(:319) |
| 流式朗读 | beginStream/appendToStream/endStream |
F5(:158/:177/:188) |
QUEUE_ADD vs QUEUE_FLUSH |
顺序播放 vs 打断式 | 流式用 ADD(:167),一次性用 FLUSH(:142) |
| 按句切分 | 累积到句末标点才念(含中英文标点) | SENTENCE_ENDINGS(:82)+ 500ms 兜底 |
| 停止要彻底 | 复位 isStreaming + 取消定时 flush | stopInternal finally + cancelPendingFlush |
| daemon 线程 | 否则进程退不干净 | isDaemon = true(:74) |
| 剪贴板 | ClipboardManager.setPrimaryClip,无需权限 |
copyMessage(:719) |
| Binder 1MB 上限 | 长文本不能塞 Intent | 走文件 + content://(12.7) |
| FileProvider | content:// + 临时授权 |
${applicationId}.fileprovider |
| ActivityResult | onCreate 注册、launch() 触发、回调给 Uri |
GetContent()(MainActivity.kt:49) |
| Uri ≠ 路径 | 作用域存储下只能拿 Uri | 用 ContentResolver 读 |
12.12 本章你学会了什么
逐条自测。任何一条打不了勾,回到对应小节再看一遍。
- 我能解释为什么
TextToSpeech构造完不能立刻 speak。 - 我知道
isInitialized闸门和@Volatile的作用。 - 我能说出
onDone和onError都要处理的理由。 - 我能复述"朗读声音中途变另一个"这个 bug 的两个根因。
- 我知道
applyLanguageIfNeeded四个分支各自解决什么。 - 我能区分
QUEUE_ADD和QUEUE_FLUSH,并知道流式为什么必须用 ADD。 - 我知道句末标点集合为什么要同时包含中英文。
- 我能解释 500ms 兜底冲刷的作用。
- 我知道 TTS 回调跑在哪个线程,以及为什么要加锁。
- 我能解释
if (utteranceId != currentUtteranceId) return这一句的作用。 - 我知道"停了又冒声"的两个来源及修复方式。
- 我知道为什么线程池要用 daemon 线程。
- 我知道复制不需要权限,以及
as?的作用。 - 我能说出 Binder 1MB 上限以及分享的正确姿势。
- 我知道 ActivityResult 的注册时机要求。
- 我知道拿到的是 Uri 而不是文件路径。
12.13 练习
练习 1:为什么 onError 也要调 onUtteranceFinished
如果只处理 onDone,会发生什么?
解题思路提示
onUtteranceFinished 的职责不只是"复位",还包括推动队列继续 。
如果某个句子朗读失败(比如引擎报错、文本含特殊字符),
onDone 还会被调用吗?
参考答案要点
-
后果 :某句朗读失败 时,
onDone不会 被调用,只会调onError。
如果onError什么都不做:isSpeaking永远是true;textQueue里剩下的句子永远不会被推动;- 用户听到一半就没声了,而且后续所有朗读都失效 (因为
isProcessingQueue卡住)。
-
本工程的解法 :
onDone和两个onError重载都指向同一个onUtteranceFinished:kotlinoverride fun onDone(utteranceId: String?) = onUtteranceFinished(utteranceId) override fun onError(utteranceId: String?) = onUtteranceFinished(utteranceId) override fun onError(utteranceId: String?, errorCode: Int) = onUtteranceFinished(utteranceId) -
思路推广 :"成功"和"失败"都需要收尾 。
这个模式在本书反复出现:- ch10:拷贝成功或取消都要清理半成品;
- ch10:
finally保证状态复位; - ch08:
catch里要把引擎状态改回ModelReady。
写任何"有始有终"的逻辑时,都要问:失败路径的收尾写了吗?
练习 2:如果把 appliedLocale 的判断去掉会怎样
假设把 locale == appliedLocale -> { /* 不重复设置 */ } 这个分支删掉,
改成"无条件 setLanguage"。会出现什么问题?
解题思路提示
回看 12.2.1 的两个根因。每句都设置,引擎会怎么做?
参考答案要点
- 问题一:声音突变 。注释(
:61-62)说"部分引擎会因此重选声音"------
每次setLanguage()都可能触发引擎重新匹配最佳声音,
于是每念一句换一个音色,用户听到"好几个人在念"。 - 问题二:静默回退英文 。如果某次
setLanguage的语言数据缺失,
引擎会悄悄切回英文------中文内容用英文引擎念,完全听不懂。 - 问题三:性能。每句都调用 IPC(跨进程设置语言),白白增加开销。
- 本工程的三重防护 :
- 语言没变 → 不调用;
- 语言不支持 → 只告警,保持当前声音;
appliedLocale只在成功时才更新(下次还会重试)。
- 通用教训 :"幂等"的操作也未必能随便重复调用------
外部服务的副作用(引擎重选声音)才是隐藏成本。
练习 3:给 TTS 加"语速调节"
系统 TTS 支持 setSpeechRate(float)(1.0 = 正常)。
请设计:从设置页一路到 TTSManager 该怎么接?
解题思路提示
参考 ch03 讲的 AppSettings(Flow + setter + 默认值),
以及 ch04 讲的设置项如何一路传到引擎(ch19 的采样参数是同样的链路)。
想想语速应该在哪一步应用。
参考答案要点
实现路径(和采样参数 F3 完全同构):
-
AppSettings加两项 (参考AppSettings.kt:41):kotlinval speechRate: Flow<Float> = context.dataStore.data.map { it[RATE_KEY] ?: DEFAULT_SPEECH_RATE } suspend fun setSpeechRate(v: Float) = context.dataStore.edit { it[RATE_KEY] = v } // companion: const val DEFAULT_SPEECH_RATE = 1.0f -
MainScreenState加状态 + 监听 (参考:109、:380-382):kotlinvar speechRate by mutableStateOf(AppSettings.DEFAULT_SPEECH_RATE); private set // observeSettings 里: settingsJobs.add(scope.launch { appSettings.speechRate.collect { speechRate = it; applyTtsSettings() } }) -
TTSManager加方法 :kotlinfun setSpeechRate(rate: Float) { lock.withLock { tts.setSpeechRate(rate) } }注意加锁------它可能在任意线程被调用。
-
设置页加 Slider (参考
SettingsScreen.kt的SamplingSettingsCard)。
关键设计点:
- 什么时候应用 ?两种选择:
- 每次
speakText前调用(简单,但重复 IPC); - 只在值变化时调用一次(更好,参考"语言防回退"的思路)。
推荐后者------这正是本章 12.2 教的原则。
- 每次
- 要不要持久化?要(用
AppSettings,和主题/采样参数一样)。 - 语调(
setPitch)同理,可以一起加。
这个练习的价值 :它把 ch03(AppSettings)、ch09(状态层监听)、
ch12(TTS + 加锁)串成一条完整链路------这是本项目"从 UI 到系统能力"的标准接法。
练习 4:为什么 stopInternal() 要放在 finally 里复位
看 stopInternal(:207-223),为什么 isSpeaking/textQueue 等的复位要写在 finally,
而不是 try 块的最后?
解题思路提示
如果 tts.stop() 抛异常(比如引擎已崩溃),try 块里后面的代码还会执行吗?
参考答案要点
- 如果写在 try 块最后 :
tts.stop()抛异常 → 后面的复位代码被跳过
→isSpeaking仍为true、textQueue仍有残留 → 后续朗读全部失效(卡死)。 - 写在
finally:无论正常还是异常,复位一定执行 。
即使 TTS 引擎崩了,我们的内部状态也是干净的,下次还能正常工作。 - 本工程还在
catch里只记日志、不重抛 (:211-212)------
这是 ch10 讲的"清理逻辑沉默地失败",避免 stop 失败影响主流程。 - 同类模式回顾 :
- ch10
TTSManager.shutdown()的 finally 复位(:391-398); - ch10
ModelImporter的deletePartialFile用runCatching。
- ch10
口诀:状态复位放 finally,清理失败不外抛。
练习 5:为什么分享必须走 FileProvider 而不是直接给路径
有人说"我把文件路径写在 Intent 里不就行了?"请从权限 和版本两个角度反驳。
解题思路提示
你的文件在 App 私有目录(cache/exports/)。别的 App 能直接读吗?
另外 Android 7+ 对 file:// URI 有什么限制?
参考答案要点
角度一:权限(一直都有的问题)
- 导出的文件在 App 私有目录 (
context.cacheDir/exports/),
其它 App 没有权限直接读取------你把路径给它,它打不开。 - 即使文件在公共目录,Android 10+ 的作用域存储也限制了随意访问。
角度二:版本限制(Android 7+ 的硬性规定)
-
从 Android 7.0(API 24)起,把
file://URI 传给其它应用 会直接抛:android.os.FileUriExposedException: file:///... exposed beyond app through Intent.getData() -
这是系统的安全限制,不是建议------违反了就崩。
FileProvider 的解法:
- 把私有文件映射成
content://URI(对方通过ContentResolver读,由我们的 App 代理); - 用
FLAG_GRANT_READ_URI_PERMISSION临时授权 ------
只授权这一个 URI、只在本次 Intent 有效; - 用
file_paths.xml限定哪些目录可以被分享(最小授权原则)。
一句话总结:
直接给路径 = 要么对方读不了,要么违反系统规定直接崩。
FileProvider = 既能让对方读,又把权限控制在最小范围。
延伸 :这也是为什么本工程在 Manifest 里声明了
android:grantUriPermissions="true" 和 @xml/file_paths(ch04)。
12.14 自测题(附答案)
难度递进说明:第 1--4 题是基础题 (判断对错,考本章事实);
第 5--7 题是应用题 (从选项里分辨概念,TTS 队列模式和线程是重点);
第 8--10 题是综合题 (要能讲出"为什么",尤其第 9 题要说出"停了又冒声"的两个来源)。
建议先合上书写一遍,再翻答案对。
一、判断对错
TextToSpeech构造完就能立刻speak()。- TTS 的进度回调(
onDone/onError)跑在主线程。 - 复制文本到剪贴板需要申请权限。
registerForActivityResult可以在任意时刻注册。
二、选择
- 流式朗读必须 用哪个队列模式?
A.QUEUE_FLUSHB.QUEUE_ADDC. 都可以 D. 不需要 - 分享一段长 对话应该怎么做?
A.putExtra(EXTRA_TEXT, 长文本)B. 写文件 +content://URI C. 拼成超长 URL D. 直接给文件路径 - 为什么 TTS 的内部状态要加锁 ?
A. 为了性能 B. 回调在 Binder 线程,而speak/stop可能来自任意协程线程 C. 语法要求 D. 其实不用加
三、简答
- 为什么"每句都
setLanguage"会导致"朗读声音中途变成另一个"? - "停了又冒声"有哪两个来源?各自怎么修?
- 为什么
ActivityResult的 launcher 必须在onCreate注册?
答案与解析
一、判断
- ❌ 错 。TTS 初始化是异步回调 :构造返回时引擎可能还没准备好。
必须等onInit回调并检查status == SUCCESS,用isInitialized当闸门(12.1)。 - ❌ 错 。跑在系统的 Binder 线程 (不是主线程、也不是你调用的线程)。
所以所有状态访问都要加锁(12.4)。 - ❌ 错 。复制不需要任何权限(因为是用户主动触发的安全操作)(12.6.2)。
- ❌ 错 。必须在
onCreate里注册,否则会抛
IllegalStateException: ... is attempting to register while current state is RESUMED(12.8.2)。
规则 :注册一次(onCreate),launch 多次。
二、选择
- B 。用
QUEUE_FLUSH的话,每来一个新句子都会打断 上一句,
听感是"不断被打断"(12.3.3)。 - B 。Binder 有约 1MB 上限 ,长文本会抛
TransactionTooLargeException(12.7.1)。 - B(12.4.2)。
三、简答(要点)
- 两个根因(12.2.1):
① 每句都setLanguage()→ 部分引擎会因此重新选择声音 ;
② 语言数据缺失时 → 引擎静默回退成英文 。
修复:只在语言真的变了 时才设置;语言不支持时只告警、保持当前声音 ,
而且appliedLocale只在成功时才更新(12.2.2)。 - 两个来源:
①stopInternal没复位isStreaming→appendToStream还会继续排队
(修复:在finally里isStreaming = false)(12.5.1);
② 延迟 flush 的定时任务没被取消
(修复:保存ScheduledFuture句柄并cancelPendingFlush())(12.5.2)。 - 因为
registerForActivityResult会创建一个与生命周期绑定的观察者 。
在onCreate之外(比如点击事件里)调用会因为生命周期已过STARTED而抛异常(12.8.2)。
评分建议 :第 1、2、4、9 题最常错。
本章的 TTS 部分涉及"异步 + 多线程",是全书最需要耐心的系统能力之一。
下一章 :ch13 C++ 与 JNI 最小必需。
到这里,第二部分(Kotlin/Android 应用开发)就结束了 ------
你已经能独立写出这个 App 的全部界面与业务逻辑。
下一章开始第三部分:原生与推理 。
我们要跨过 JNI 这道边界,去 C++ 那边看看:
为什么所有 native 方法都要
std::lock_guard、函数名
Java_com_arm_aichat_internal_...是怎么拼出来的、以及怎么自己加一个 JNI 方法并调通。