AB
AiBoss
チュートリアル

TextToSpeech

チュートリアル

用 Android 原生 TextToSpeech 实现中文文章朗读

面向 Android 开发者的 TextToSpeech 实操教程:从初始化、语言检测、语速音调设置,到长文本分段播报、进度回调与完整示例,讲清如何用系统原生 TTS 引擎把文章文字朗读出来,并说明引擎差异与常见坑。

在 Android 应用里做「点击播放、把文章念出来」这类功能,可选方案不少:第三方语音服务、云厂商的语音合成接口,或者系统自带的 TextToSpeech。前几类通常要接入 SDK、申请密钥、按量计费,对一个只做朗读的小功能来说维护成本偏高。Android 原生提供的 TextToSpeech 则不需要额外依赖,调用系统已安装的语音引擎即可发声,在多数机型上也能朗读中文,只是实际使用的引擎和语音包因设备而异。这篇教程讲的是如何用原生 TextToSpeech 封装一个可复用的朗读管理器,覆盖初始化、语言选择、语速音调、长文本分段、播放进度回调这些环节。如果你只是想先确认一下这个能力大致是什么,可以先看 TextToSpeech 的说明,再回到本文动手写代码。

适合阅读本文的读者:已经能写 Kotlin、了解 Android 基本组件生命周期,需要在 App 内加入「朗读正文」「听文章」功能的开发者。本文不涉及语音识别、音频文件导出,也不讨论自建语音合成服务。

准备工作

运行环境与依赖

TextToSpeech 是 Android 框架自带的类,位于 android.speech.tts 包下,不需要在 Gradle 里添加任何第三方依赖。你需要的是:

  • 一个可以运行 Android 应用的开发环境,能编译 Kotlin 代码;
  • 一台真机或模拟器,且设备上已安装至少一个语音引擎(多数手机出厂自带,模拟器镜像可能缺失,需要自行确认);
  • 如果要在界面上触发朗读,需要一个按钮或类似的交互入口。

代码里会用到 Toast 提示,因此需要准备几个字符串资源,例如语音数据缺失、语言不支持、系统版本不支持这三类提示文案。示例中写作 R.string.voice_missing_data、R.string.voice_lang_not_supported、R.string.voice_android_version_not_supported,你可以按项目命名习惯替换。

确认设备上的语音引擎

原生 TextToSpeech 只是一个调用入口,真正发声的是设备上安装的语音引擎。不同厂商的默认引擎不一样,例如部分小米机型默认使用小爱同学引擎。这意味着同一份代码在不同设备上音色、可用语言可能不同。若设备缺少中文语音数据,朗读会失败并返回语言数据缺失的错误。开发前建议先在目标机型上确认系统设置里存在可用的语音引擎与中文语音包。

关于中文支持

原生 TextToSpeech 目前已经支持中文播报,在小米、华为、vivo 等机型上实测可以朗读中文,差别只在底层引擎。因此不必为了中文而引入第三方服务,但必须在代码里做语言可用性判断,不能假定中文一定可用。

操作步骤

第一步:初始化 TextToSpeech 并记录状态

TextToSpeech 的构造是异步的,初始化结果通过回调返回。回调里的 status 等于 TextToSpeech.SUCCESS 表示引擎就绪,否则视为失败。把结果存到一个成员变量里,后续每次朗读前都据此判断能否继续。

class TextToSpeechManager(context: Context) {
    private var ttsErrorStatus: Int = 0
    private var tts: TextToSpeech? = null

    init {
        tts = TextToSpeech(context) { status ->
            ttsErrorStatus = if (status == TextToSpeech.SUCCESS) {
                TextToSpeech.SUCCESS
            } else {
                TextToSpeech.ERROR
            }
        }
    }
}

注意初始化回调可能在构造语句之后才执行,所以不要在构造函数里紧接着就调用 speak,而应把语言设置和朗读动作放在回调成功之后,或者在使用时再判断状态。

第二步:检测并设置中文语言

引擎就绪后,先读取 availableLanguages,再决定设置哪种中文区域。判断顺序建议为:简体中文、繁体中文,最后回退到 Locale.CHINESE。如果列表为空或都不包含,则记为语言不支持。

val tts = tts
if (ttsErrorStatus == TextToSpeech.SUCCESS && tts != null) {
    ttsErrorStatus = when {
        tts.availableLanguages == null -> TextToSpeech.LANG_NOT_SUPPORTED
        tts.availableLanguages.contains(Locale.SIMPLIFIED_CHINESE) ->
            tts.setLanguage(Locale.SIMPLIFIED_CHINESE)
        tts.availableLanguages.contains(Locale.TRADITIONAL_CHINESE) ->
            tts.setLanguage(Locale.TRADITIONAL_CHINESE)
        tts.availableLanguages.contains(Locale.CHINESE) ->
            tts.setLanguage(Locale.CHINESE)
        else -> TextToSpeech.LANG_NOT_SUPPORTED
    }
}

setLanguage 的返回值同样是状态码,可能是 LANG_MISSING_DATA 或 LANG_NOT_SUPPORTED,把它直接赋给 ttsErrorStatus,朗读前统一判断即可。

第三步:提供语速与音调设置

两个参数都用 Float 传入。音调默认值为 1.0,值越大声音越尖,值越小越接近男声;语速默认值为 1。封装成方法时先判空,避免引擎未就绪时崩溃。

fun setPitch(pitch: Float) {
    val tts = tts ?: return
    tts.setPitch(pitch)
}

fun setSpeechRate(rate: Float) {
    val tts = tts ?: return
    tts.setSpeechRate(rate)
}

第四步:把长文本切分成段

单次 speak 传入的文本不能过长,超过 3999 个字符会朗读出错。因此需要先把正文按固定长度切分。下面的方法按 segmentLength 切分,默认 3999,返回字符串数组。

private fun genSegment(originStr: String, segmentLength: Int = 3999): Array<String?> {
    val originLength = originStr.length
    val arraySize = originLength / segmentLength + 1
    val result = arrayOfNulls<String>(arraySize)
    for (i in 0 until arraySize) {
        result[i] = originStr.substring(
            i * segmentLength,
            min((i + 1) * segmentLength, originLength)
        )
    }
    return result
}

切分长度不要超过 3999,否则会触发朗读错误。切分是按字符数硬切的,可能在句子中间断开,如果对停顿有要求,可以在切分前按标点做一次预处理。

第五步:逐段入队并处理播放状态

把切好的段落依次交给 speak,队列模式用 QUEUE_ADD,表示等前面的语音任务播完再播这一段。如果用户再次点击播放,先用 isSpeaking 判断是否正在朗读:正在朗读就调用 stop() 停止,否则开始新的朗读。

fun speak(
    content: String,
    utteranceProgressListener: GetLastSegUtteranceProgressListener
) {
    val tts = tts ?: return
    tts.setOnUtteranceProgressListener(utteranceProgressListener)
    when (ttsErrorStatus) {
        TextToSpeech.LANG_MISSING_DATA -> showToast(R.string.voice_missing_data)
        TextToSpeech.LANG_NOT_SUPPORTED -> showToast(R.string.voice_lang_not_supported)
        ANDROID_VERSION_NOT_SUPPORT -> showToast(R.string.voice_android_version_not_supported)
        else -> {
            if (tts.isSpeaking) {
                tts.stop()
            } else {
                val textSeg = genSegment(content, 10)
                utteranceProgressListener.setLastSegId(SEG_PREFIX + (textSeg.size - 1))
                for (i in textSeg.indices) {
                    tts.speak(textSeg[i], TextToSpeech.QUEUE_ADD, null, SEG_PREFIX + i)
                }
            }
        }
    }
}

这里有两个细节值得注意。第一,QUEUE_ADD 与 QUEUE_FLUSH 语义不同:前者等之前的任务播完再播本次内容,后者丢弃之前的任务立即播报本次内容。分段连续朗读要用 QUEUE_ADD。第二,每个段落都带一个 utteranceId,示例里用 SEG_PREFIX + i 生成,前缀为 seg,这个 id 是后面判断「是否播到最后一段」的依据。

第六步:扩展进度回调,识别最后一段

系统提供的 UtteranceProgressListener 的 onDone 每段结束都会回调一次,但不会告诉你是不是最后一段。可以继承它,记录最后一段的 id,在 onDone 里比对后转成一个带 isLastSeg 参数的抽象方法。

abstract class GetLastSegUtteranceProgressListener : UtteranceProgressListener() {
    private var lastSegId = ""

    fun setLastSegId(lastSegId: String) {
        this.lastSegId = lastSegId
    }

    override fun onDone(utteranceId: String?) {
        onDone(utteranceId, lastSegId == utteranceId)
    }

    abstract fun onDone(utteranceId: String?, isLastSeg: Boolean)
}

调用方只需要关心 isLastSeg 为 true 的那一次回调,用来恢复播放按钮状态、更新进度等。

第七步:估算朗读时长

如果界面上要显示「约几分钟」,可以按字符数粗略换算。示例中的做法是每 3.7 个字符约一秒,再除以 60 得到分钟数。

fun getSpeechLength(content: String): Int {
    val totalSecond = (content.length / 3.7).toInt()
    return totalSecond / 60
}

这只是估算值,实际时长受语速设置、引擎和文本内容影响,不要当作精确进度使用。

一个完整示例

把上面的片段合起来,就是一个可用的管理器。完整代码如下:

class TextToSpeechManager(context: Context) {
    private var ttsErrorStatus: Int = 0
    private var tts: TextToSpeech? = null

    init {
        tts = TextToSpeech(context) { status ->
            ttsErrorStatus = if (status == TextToSpeech.SUCCESS) {
                TextToSpeech.SUCCESS
            } else {
                TextToSpeech.ERROR
            }
        }
        val tts = tts
        if (ttsErrorStatus == TextToSpeech.SUCCESS && tts != null) {
            ttsErrorStatus = when {
                tts.availableLanguages == null -> TextToSpeech.LANG_NOT_SUPPORTED
                tts.availableLanguages.contains(Locale.SIMPLIFIED_CHINESE) ->
                    tts.setLanguage(Locale.SIMPLIFIED_CHINESE)
                tts.availableLanguages.contains(Locale.TRADITIONAL_CHINESE) ->
                    tts.setLanguage(Locale.TRADITIONAL_CHINESE)
                tts.availableLanguages.contains(Locale.CHINESE) ->
                    tts.setLanguage(Locale.CHINESE)
                else -> TextToSpeech.LANG_NOT_SUPPORTED
            }
        }
    }

    fun setPitch(pitch: Float) {
        val tts = tts ?: return
        tts.setPitch(pitch)
    }

    fun setSpeechRate(rate: Float) {
        val tts = tts ?: return
        tts.setSpeechRate(rate)
    }

    fun speak(
        content: String,
        utteranceProgressListener: GetLastSegUtteranceProgressListener
    ) {
        val tts = tts ?: return
        tts.setOnUtteranceProgressListener(utteranceProgressListener)
        when (ttsErrorStatus) {
            TextToSpeech.LANG_MISSING_DATA -> showToast(R.string.voice_missing_data)
            TextToSpeech.LANG_NOT_SUPPORTED -> showToast(R.string.voice_lang_not_supported)
            ANDROID_VERSION_NOT_SUPPORT -> showToast(R.string.voice_android_version_not_supported)
            else -> {
                if (tts.isSpeaking) {
                    tts.stop()
                } else {
                    val textSeg = genSegment(content, 10)
                    utteranceProgressListener.setLastSegId(SEG_PREFIX + (textSeg.size - 1))
                    for (i in textSeg.indices) {
                        tts.speak(textSeg[i], TextToSpeech.QUEUE_ADD, null, SEG_PREFIX + i)
                    }
                }
            }
        }
    }

    private fun genSegment(originStr: String, segmentLength: Int = 3999): Array<String?> {
        val originLength = originStr.length
        val arraySize = originLength / segmentLength + 1
        val result = arrayOfNulls<String>(arraySize)
        for (i in 0 until arraySize) {
            result[i] = originStr.substring(
                i * segmentLength,
                min((i + 1) * segmentLength, originLength)
            )
        }
        return result
    }

    fun getSpeechLength(content: String): Int {
        val totalSecond = (content.length / 3.7).toInt()
        return totalSecond / 60
    }

    @SuppressLint("NewApi")
    abstract class GetLastSegUtteranceProgressListener : UtteranceProgressListener() {
        private var lastSegId = ""

        fun setLastSegId(lastSegId: String) {
            this.lastSegId = lastSegId
        }

        override fun onDone(utteranceId: String?) {
            onDone(utteranceId, lastSegId == utteranceId)
        }

        abstract fun onDone(utteranceId: String?, isLastSeg: Boolean)
    }

    companion object {
        private const val SEG_PREFIX = "seg"
        private const val ANDROID_VERSION_NOT_SUPPORT = -3
    }
}

在界面里使用它,只需要创建实例,把要朗读的文本和回调传进去:

val ttsManager = TextToSpeechManager(this)

ttsManager.speak(
    binding.voiceText.text.toString(),
    object : TextToSpeechManager.GetLastSegUtteranceProgressListener() {
        override fun onStart(utteranceId: String?) {
            // 开始阅读
        }

        override fun onStop(utteranceId: String?, interrupted: Boolean) {
            // 停止阅读
        }

        override fun onDone(utteranceId: String?, isLastSeg: Boolean) {
            // 每一段结束时调用;通过 stop() 停止则不会回调这里
            if (isLastSeg) {
                // 这里是最后一段
            }
        }

        override fun onError(utteranceId: String?) {
            // 阅读出错
        }
    }
)

这个最小示例的流程是:构造管理器 → 引擎就绪后设置中文 → 点击播放时把正文按段入队 → 每段结束回调一次 → 最后一段结束时恢复界面状态。如果用户中途再次点击,isSpeaking 为 true 会走 stop() 分支,朗读停止。

注意事项

单段长度上限

单次传入的文本超过 3999 个字符会朗读出错,这是分段逻辑存在的原因。分段长度参数不要随意调大,示例中调用 genSegment(content, 10) 时传了 10,属于演示用法,实际使用建议保持默认的 3999 或更小的合理值。

stop() 之后不会触发 onDone

通过 stop() 主动停止朗读时,onDone 不会被调用。因此不要只依赖 onDone 来恢复播放按钮状态,停止操作后应在 onStop 或调用处自行更新界面。

语言与语音数据缺失

设备没有安装对应语言的语音数据时,setLanguage 会返回 LANG_MISSING_DATA;引擎完全不支持该语言时返回 LANG_NOT_SUPPORTED。这两种情况都要给用户明确提示,而不是静默失败。不同厂商的默认引擎不同,同一份代码在不同设备上的可用语言可能不一致,测试时要在目标机型上验证。

初始化是异步的

构造 TextToSpeech 时传入的回调可能晚于后续代码执行,因此在回调完成前调用 speak 或 setLanguage 可能拿不到预期结果。示例中在构造后立即读取 ttsErrorStatus 并设置语言,属于简化写法;在真实项目中更稳妥的做法是把语言设置放进初始化回调内部,或在使用前再次校验状态。

队列模式的选择

QUEUE_ADD 适合连续朗读多段文本;QUEUE_FLUSH 会丢弃之前的任务立即播报,适合「换一篇重新读」的场景。选错模式会出现前一段没播完就被打断,或者新内容排在旧内容之后很久才响。

时长估算不是精确值

getSpeechLength 按每 3.7 个字符约一秒换算,结果受语速、引擎、文本内容影响,只适合做粗略展示。若需要精确进度,应结合 onStart、onDone 等回调按段统计。

资源释放

TextToSpeech 持有系统引擎资源,页面或组件销毁时应调用 shutdown() 释放,避免引擎连接长期占用。示例代码聚焦朗读逻辑,未包含释放环节,实际项目中需要补上。

最后提醒一句:语音引擎的可用性、系统版本行为、各厂商默认引擎的差异都可能随设备和系统更新变化,具体表现请以目标设备与官方当前信息为准。