ch12 系统能力接入:TTS、剪贴板、分享、文件选择器

学习目标

读完本章,你应当能够:

  1. 理解 TextToSpeech初始化是异步回调------不能"建完就念";
  2. 看懂本工程"流式朗读 ":按句切分 + QUEUE_ADD 队列模式,让模型边生成边出声;
  3. 说清 UtteranceProgressListener 跑在哪个线程、为什么内部必须加锁;
  4. 理解"语言防回退 "这个真实修复:为什么不能每句都 setLanguage()
  5. 会用 ClipboardManager 复制文本;
  6. 会用 FileProvider 分享文件,并说清为什么不能直接塞 EXTRA_TEXT
  7. 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)、
    UtteranceProgressListenerBinder 线程锁(lock)@Volatile
    daemon 线程、ClipboardManagerFileProviderActivityResultContracts
    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 线程。 别记反了。
🛟 为什么初始化失败必须兜底? onInitstatus 可能不是 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

现象:用户反馈"朗读到一半,声音突然变成另一个人"。

两个根因

  1. 每句都调 setLanguage() → 部分引擎会重新选择声音
  2. 语言数据缺失时 → 引擎静默回退成英文(中文用英文引擎念,非常诡异)。

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) 不支持时只告警、保持当前声音

📌 最后那个"不支持时不回退"是关键

kotlin 复制代码
if (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 的状态(isSpeakingtextQueuecurrentUtteranceId......)

会被至少两类线程同时访问:

线程 做什么
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),
    上面写明"只允许取这一件物品、本次交易有效",邻居凭牌拿货,牌一过期就作废

通行证既让邻居拿到了他要的东西,又不会暴露你整个保险箱------这就是"最小授权"。

为什么需要? 三层原因叠在一起:

  1. 权限:私有目录别的 App 本来就没权限读,你给路径它也打不开;
  2. 安全:直接暴露路径等于"钥匙上门",别的 App 可能顺着猜到你别的文件路径;
  3. 版本 :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 步骤

  1. 加载模型,开启 TTS 开关(顶栏的 Switch)。
  2. 发送一条中英混排 的消息,例如: 请用中文和 English 混合回答,介绍一下 Transformer 是什么?
  3. 观察:
    • 是否在模型还在吐字时就开始朗读(流式生效);
    • 中英文是否用同一个声音连续念下来(没有中途变声);
    • 句末(/.)处是否有自然停顿。

12.10.2 加日志观察(可选)

speakTextTTSManager.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=1QUEUE_ADD0QUEUE_FLUSH------流式下应该一直是 1
  • 能看到按句切分的效果(每句一次 speak,而不是每字一次)。

12.10.3 对照实验:改成 QUEUE_FLUSH

beginStreamactiveQueueMode = TextToSpeech.QUEUE_ADD 改成 QUEUE_FLUSH

再跑一次------你会听到不断被打断 的效果。

这能让你直观理解两个队列模式的差别(做完记得改回来)。


12.10.4 如果没声音,按顺序排查

  1. 顶栏 TTS 开关打开了吗? 没开的话根本不会调朗读,先打开。
  2. 手机本身有没有声音、媒体音量是不是零 :按音量键加一下;有些模拟器根本没有 TTS 引擎
    怎么试都没声------这种情况请用真机,模拟器上能验证"日志里有没有走 speak",但听不到声音是正常的。
  3. AI 回复里真的有中文吗? 让模型先回复一句明确的中文(比如"你好,我是助手")再观察,
    别一上来就测中英混排。
  4. 看日志 :过滤 TTSManager,如果根本没有 TTS initialized successfully
    说明初始化就失败了(设备没装 TTS 引擎或缺语音数据)------这是设备问题,不是本工程代码问题。
  5. 做了 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/onErroronUtteranceFinished: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 的作用。
  • 我能说出 onDoneonError 都要处理的理由。
  • 我能复述"朗读声音中途变另一个"这个 bug 的两个根因。
  • 我知道 applyLanguageIfNeeded 四个分支各自解决什么。
  • 我能区分 QUEUE_ADDQUEUE_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

    kotlin 复制代码
    override 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(跨进程设置语言),白白增加开销。
  • 本工程的三重防护
    1. 语言没变 → 不调用;
    2. 语言不支持 → 只告警,保持当前声音
    3. appliedLocale 只在成功时才更新(下次还会重试)。
  • 通用教训 :"幂等"的操作也未必能随便重复调用------
    外部服务的副作用(引擎重选声音)才是隐藏成本。

练习 3:给 TTS 加"语速调节"

系统 TTS 支持 setSpeechRate(float)(1.0 = 正常)。

请设计:从设置页一路到 TTSManager 该怎么接?
解题思路提示

参考 ch03 讲的 AppSettings(Flow + setter + 默认值),

以及 ch04 讲的设置项如何一路传到引擎(ch19 的采样参数是同样的链路)。

想想语速应该在哪一步应用。
参考答案要点

实现路径(和采样参数 F3 完全同构):

  1. AppSettings 加两项 (参考 AppSettings.kt:41):

    kotlin 复制代码
    val 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
  2. MainScreenState 加状态 + 监听 (参考 :109:380-382):

    kotlin 复制代码
    var speechRate by mutableStateOf(AppSettings.DEFAULT_SPEECH_RATE); private set
    // observeSettings 里:
    settingsJobs.add(scope.launch { appSettings.speechRate.collect { speechRate = it; applyTtsSettings() } })
  3. TTSManager 加方法

    kotlin 复制代码
    fun setSpeechRate(rate: Float) { lock.withLock { tts.setSpeechRate(rate) } }

    注意加锁------它可能在任意线程被调用。

  4. 设置页加 Slider (参考 SettingsScreen.ktSamplingSettingsCard)。

关键设计点

  • 什么时候应用 ?两种选择:
    • 每次 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 仍为 truetextQueue 仍有残留 → 后续朗读全部失效(卡死)。
  • 写在 finally无论正常还是异常,复位一定执行
    即使 TTS 引擎崩了,我们的内部状态也是干净的,下次还能正常工作。
  • 本工程还在 catch只记日志、不重抛:211-212)------
    这是 ch10 讲的"清理逻辑沉默地失败",避免 stop 失败影响主流程。
  • 同类模式回顾
    • ch10 TTSManager.shutdown() 的 finally 复位(:391-398);
    • ch10 ModelImporterdeletePartialFilerunCatching

口诀:状态复位放 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 的解法

  1. 把私有文件映射成 content:// URI(对方通过 ContentResolver 读,由我们的 App 代理);
  2. FLAG_GRANT_READ_URI_PERMISSION 临时授权 ------
    只授权这一个 URI、只在本次 Intent 有效;
  3. file_paths.xml 限定哪些目录可以被分享(最小授权原则)。

一句话总结

直接给路径 = 要么对方读不了,要么违反系统规定直接崩。

FileProvider = 既能让对方读,又把权限控制在最小范围

延伸 :这也是为什么本工程在 Manifest 里声明了

android:grantUriPermissions="true"@xml/file_paths(ch04)。


12.14 自测题(附答案)

难度递进说明:第 1--4 题是基础题 (判断对错,考本章事实);

第 5--7 题是应用题 (从选项里分辨概念,TTS 队列模式和线程是重点);

第 8--10 题是综合题 (要能讲出"为什么",尤其第 9 题要说出"停了又冒声"的两个来源)。

建议先合上书写一遍,再翻答案对。

一、判断对错

  1. TextToSpeech 构造完就能立刻 speak()
  2. TTS 的进度回调(onDone/onError)跑在主线程
  3. 复制文本到剪贴板需要申请权限
  4. registerForActivityResult 可以在任意时刻注册。

二、选择

  1. 流式朗读必须 用哪个队列模式?
    A. QUEUE_FLUSH B. QUEUE_ADD C. 都可以 D. 不需要
  2. 分享一段 对话应该怎么做?
    A. putExtra(EXTRA_TEXT, 长文本) B. 写文件 + content:// URI C. 拼成超长 URL D. 直接给文件路径
  3. 为什么 TTS 的内部状态要加锁
    A. 为了性能 B. 回调在 Binder 线程,而 speak/stop 可能来自任意协程线程 C. 语法要求 D. 其实不用加

三、简答

  1. 为什么"每句都 setLanguage"会导致"朗读声音中途变成另一个"?
  2. "停了又冒声"有哪两个来源?各自怎么修?
  3. 为什么 ActivityResult 的 launcher 必须在 onCreate 注册

答案与解析

一、判断

  1. 。TTS 初始化是异步回调 :构造返回时引擎可能还没准备好。
    必须等 onInit 回调并检查 status == SUCCESS,用 isInitialized 当闸门(12.1)。
  2. 。跑在系统的 Binder 线程 (不是主线程、也不是你调用的线程)。
    所以所有状态访问都要加锁(12.4)。
  3. 。复制不需要任何权限(因为是用户主动触发的安全操作)(12.6.2)。
  4. 。必须在 onCreate 里注册,否则会抛
    IllegalStateException: ... is attempting to register while current state is RESUMED(12.8.2)。
    规则注册一次(onCreate),launch 多次。

二、选择

  1. B 。用 QUEUE_FLUSH 的话,每来一个新句子都会打断 上一句,
    听感是"不断被打断"(12.3.3)。
  2. B 。Binder 有约 1MB 上限 ,长文本会抛 TransactionTooLargeException(12.7.1)。
  3. B(12.4.2)。

三、简答(要点)

  1. 两个根因(12.2.1):
    ① 每句都 setLanguage()部分引擎会因此重新选择声音
    ② 语言数据缺失时 → 引擎静默回退成英文
    修复:只在语言真的变了 时才设置;语言不支持时只告警、保持当前声音
    而且 appliedLocale 只在成功时才更新(12.2.2)。
  2. 两个来源:
    stopInternal 没复位 isStreamingappendToStream 还会继续排队
    (修复:在 finallyisStreaming = false)(12.5.1);
    ② 延迟 flush 的定时任务没被取消
    (修复:保存 ScheduledFuture 句柄并 cancelPendingFlush())(12.5.2)。
  3. 因为 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 方法并调通。

相关推荐
传奇开心果编程2 小时前
【Jetpack Compose基础语法学与练】第9课 SideEffect副作用 + LaunchedEffect,处理异步任务和生命周期
android·学习·ui·kotlin·android jetpack
不要喷香水1 天前
00-总览与架构
kotlin·app·档案管理
hai_android1 天前
Android 组件化开发实践
android·java·kotlin
传奇开心果编程2 天前
【Jetpack Compose基础语法学与练】第6课 TextField文本输入,字符串状态与输入交互
学习·前端框架·kotlin·android jetpack
ai2work2 天前
ch06 Jetpack Compose 入门:状态驱动 UI
kotlin
JMchen3 天前
第 12 篇|项目整合与打包发布 —— 从 Demo 到可安装 APK 的完整收官指南
kotlin·android studio
Android打工仔3 天前
不要在 Data 层随意把 Cold Flow 转换成 Hot Flow
android·架构·kotlin
杉氧3 天前
拒绝重复造轮子:我写了一个生产级的 Kotlin 协程与 Flow 工具库(CoroutineKit)
android·kotlin·workflow
JMchen4 天前
第 9 篇|网络请求基础 —— 从 HttpURLConnection 到 OkHttp
kotlin·android studio