【Android】听写播放器改版:中英文书写速度拆项与「正在读」高亮

一个"书写速度 10"的设置,对中文是每分钟 10 字,对英文是每分钟 10 个字母------量级差一倍多,怎么填都有一半是错的;播放页又在静默节拍把文字换成"书写时间...",用户连刚才读到哪都看不见。这篇记录这两处改版:把速度按语种拆成两项(含 Room 9→10 迁移),把播放页改成"全文常驻 + 高亮游标",以及改的过程中踩的 5 个坑。

1. 背景与目标

这是一个纯离线的 Android 听写 App(Compose + Room + 系统 TTS + Media3 ExoPlayer)。听写节奏由一条时间轴驱动:

复制代码
切块(按标点 / 每 N 个单元)
  → 块预算 B = 加权书写单元数 × 每单元书写毫秒(由速度决定,另有词保底)
  → 总静默 S = max(B − 朗读时长, 遍数 × 最小间隔)
  → 每遍之间的停顿 gap = clamp(S / 遍数, 最小间隔, 静默上限)
  → 播放时间轴:1 个节拍 = 1 段语音 或 1 段静默

改版前的两个问题:

问题一:速度只有一个数字。 "10" 这个值在中文里是"每分钟写 10 个汉字",在英文里是"每分钟写 10 个字母"。1 个汉字约等于 2.86 个字母的书写时间,所以同一个数字对两种语言的含义差 2~3 倍。真实用户不会同时用两种语言,但会在"今天听中文、明天听英文"之间切换------于是每次切换都得回去改设置,忘了改就变成英文太慢、中文太快。

问题二:播放页把参照物拿走了。 原实现是"一次只显示当前块大字":读的时候显示这块,进入静默节拍就显示 书写时间...。对用户来说,静默期恰恰是最需要看着文字的时段,界面却把文字换成了一个占位提示;如果一段课文有 20 块,用户也无法判断"现在读的是第几句、还剩多少"。

目标:

  1. 中文、英文各一项速度,互不影响,各自 6~100;
  2. 播放页全文常驻,正在朗读的块高亮,静默期保持这块高亮(不切走);
  3. 老用户数据零丢失(已写好的设置、会话记录不能因为改结构而丢);
  4. 不回归:已有 115 项单测继续全绿。

2. 技术选型

2.1 速度配置怎么落地

维度 方案 A:单列 + 语种自适应(当前) 方案 B:拆两列(采纳) 方案 C:统一物理量(毫秒/书写单元)
用户看到的语义 一个数字,单位随语种变 两个输入框,单位写在标题里 一个数字,单位是毫秒
默认值合理性 必然有一边是错的 各自合理 合理,但用户没法凭感觉填
迁移成本 0 加一列 + 字段映射 需要把旧值按语种反算成毫秒
误用风险 高(切语种忘改) 低 高(6000 和 1000 差一个数量级,填错就是灾难)
可解释性 中 高("每分钟多少字/字母"能自我校验) 低

选 B 。方案 C 从工程角度最"干净"------把速度抽象成一个物理量,上层只认 perUnitMs,但它把换算成本推给了用户。用户脑子里装的是"我写字一分钟能写 20 个字",不是"每个字写 3000 毫秒"。所以物理量适合做内部表示 ,不适合做输入界面。

2.2 播放页怎么展示

维度 方案 A:只渲染当前块(当前) 方案 B:整段一个 Text + AnnotatedString 区间样式 方案 C:每块一行,LazyColumn(采纳)
长文本 无压力(只画一块) 全文一个节点,长文本排版/测量偏慢 按需渲染,天然支持长文本
高亮跟随 天然准确 需要算字符区间,块位置随切块变化易错 按索引定位,简单
能否挂每块附属信息 只能挂当前块 不能(只有样式) 能(如"拼读遍"小字、错字标记)
用户能否看到上下文 不能 能 能

选 C。方案 B 的"一段连续性文本"视觉上最像课本,但块要挂附属信息(英文拼读遍)时就没地方放了,而且块索引到字符区间的映射在超长块、标点切分下有空白字符的坑,收益不抵风险。

3. 架构设计

3.1 演进:一个数字变成两项,代价转移到哪

版本 settings 听写列数 速度语义 复杂度位置
v9 13 单值,引擎内部按语种换单位 隐式:引擎先判语种,再决定这个数字表示字还是字母
v10 14 中/英各一项 显式:配置层分叉,引擎只认"按语种取速度"这一个函数

演进后复杂度并没有消失,而是从"引擎里隐式的单位约定"搬到了"配置层的显式分叉 + 一个统一取值入口"。这一步值得做,因为隐式约定只能靠注释和记忆维持,而显式分叉可以被命名、被单测、被 UI 展示。

3.2 数据层:Room 9→10,加一列而不是改一列

kotlin 复制代码
// data/model/Settings.kt
@ColumnInfo(name = "dictationCharsPerMinute", defaultValue = "10")
val dictationEnglishCharsPerMinute: Int = 10,   // 英文速度:沿用旧列名

@ColumnInfo(defaultValue = "10")
val dictationChineseCharsPerMinute: Int = 10,   // 中文速度:v10 新增列
kotlin 复制代码
// data/database/AppDatabase.kt
@Database(
    entities = [ /* 13 张表 */ ],
    version = 10,
    exportSchema = true,
    autoMigrations = [
        androidx.room.AutoMigration(from = 1, to = 2),
        // ...
        androidx.room.AutoMigration(from = 8, to = 9),  // v9:听写两表 + 13 列
        androidx.room.AutoMigration(from = 9, to = 10)  // v10:只加 dictationChineseCharsPerMinute
    ]
)

关键决策:

决策 理由
旧列物理名不变 ,只把 Kotlin 字段改名(@ColumnInfo(name = ...) 映射回旧列名) Room 迁移比对的是列名。把列 dictationCharsPerMinute 改成 dictationEnglishCharsPerMinute 等于"删一列 + 加一列",自动迁移会认为结构不匹配而失败(见坑 2)。字段名是内部命名,列名是对外契约,两者解耦
只新增一列,带 defaultValue = "10" Room 自动迁移对"加列"要求必须有默认值;旧行的中文速度自然落在 10,与 v9 的行为连续
不删不重建 项目硬约束:禁用破坏性迁移。用户已经攒了听写会话与错字库,任何"重建"都是灾难

迁移后实测(模拟器,先装 v9 再覆盖装 v10):

复制代码
Database opened successfully, current version: 10
user_version = 10
会话 5 条 / 错字 1 个 / 答题记录 11 条 ------ 全部保留
settings: dictationCharsPerMinute=10, dictationChineseCharsPerMinute=10

3.3 领域层:只留一个取值入口

kotlin 复制代码
// dictation/model/DictationModels.kt
data class DictationConfig(
    val charsPerMinute: Int = 10,        // 兜底值(旧 configJson / 未设置分语种时)
    val chineseCharsPerMinute: Int = 0,  // 0 = 未设置
    val englishCharsPerMinute: Int = 0,
    // ... 其余 9 个字段
) {
    fun speedFor(script: DictationScript): Int {
        val speed = when (script) {
            DictationScript.CHINESE -> chineseCharsPerMinute
            DictationScript.ENGLISH -> englishCharsPerMinute
        }
        return (if (speed > 0) speed else charsPerMinute).coerceIn(6, 100)
    }

    fun sanitized(): DictationConfig = copy(
        charsPerMinute = charsPerMinute.coerceIn(6, 100),
        chineseCharsPerMinute = if (chineseCharsPerMinute <= 0) 0 else chineseCharsPerMinute.coerceIn(6, 100),
        englishCharsPerMinute = if (englishCharsPerMinute <= 0) 0 else englishCharsPerMinute.coerceIn(6, 100),
        // ...
    )
}

设计要点:

  1. 0 表示"未设置"而不是"默认 10" 。听写会话会把配置序列化成 JSON 存进 dictation_session.configJson,v9 存下的 JSON 里只有 charsPerMinute 一个速度字段;反序列化成 v10 的类时,新增两个字段会取默认值 0,于是自动回落到旧的那个值。用 0 做哨兵,是为了让"历史数据"和"用户主动设了 6"能区分开------如果默认写成 10,老会话回放时就会被悄悄改成 10。
  2. 兜底值仍保留 charsPerMinute。它还有第二重身份:旧会话回放时的时间轴重建。
  3. sanitized() 里对两个新字段用 if (<=0) 0 else coerceIn(6,100),而不是直接 coerceIn(0,100),否则 1 这种非法值会被当成合法(见坑 5)。

3.4 引擎层:块预算按"块自己"的语种取速度

kotlin 复制代码
// dictation/engine/TimelineBuilder.kt
fun chunkBudgetMs(chunk: DictationChunk, config: DictationConfig): Long {
    val perUnit = 60000.0 / config.speedFor(chunk.script)   // 注意:chunk.script,不是整篇的语种
    val writing = (chunk.weightedUnits * perUnit).roundToLong()
    val floor = chunk.wordCount * DictationConstants.MIN_WORD_MS
    return maxOf(writing, floor)
}

为什么传 chunk.script 而不是整篇语种?因为中英混排的课文("春天来了 hello world")切块后,每块自带语种;用整篇的主导语种去算,混排里少数语种的那几块预算就会错。DictationChunk 在切块时就带上了 script,这里直接用是最省的写法。

端到端走读(英文 hello world,10 个字母,3 遍):

速度 每单元毫秒 块预算 B 实测总时长 遍数 遍间隔
10 字母/分 6000 ms 60 s 1 分 2 秒 6 遍(自动档) 8.0 s(顶到静默上限)
60 字母/分 1000 ms 10 s 12 秒 2 遍(自动档) 3.0 s

同一段文本、只改英文速度,时长从 62 秒降到 12 秒------这就是"一个数字管两种语言"不可行的量化证据。

3.5 播放页:全文常驻 + 高亮游标

核心是把"当前显示什么"从当前节拍 改成派生状态:

kotlin 复制代码
// presentation/dictation/DictationPlayScreen.kt
val beat = playbackState.currentBeat

// 静默节拍没有 chunkIndex,于是回看已经播过的节拍,取最近一次朗读
val lastSpeakBeat = remember(playbackState.beatIndex, timeline) {
    val end = playbackState.beatIndex.coerceIn(0, timeline.beats.size)
    timeline.beats.subList(0, end).filterIsInstance<DictationBeat.Speak>().lastOrNull()
}
val speakingBeat = (beat as? DictationBeat.Speak) ?: lastSpeakBeat
val activeChunkIndex = speakingBeat?.chunkIndex ?: -1
val readingInProgress = beat is DictationBeat.Speak
kotlin 复制代码
// 全文逐块列表,正在读的那块高亮;块用 index 做 key,自动滚动跟随
val listState = rememberLazyListState()
LaunchedEffect(activeChunkIndex) {
    if (activeChunkIndex in timeline.chunks.indices) {
        listState.animateScrollToItem(activeChunkIndex)
    }
}

LazyColumn(state = listState, modifier = Modifier.weight(1f)) {
    itemsIndexed(timeline.chunks, key = { _, chunk -> chunk.index }) { index, chunk ->
        val isActive = index == activeChunkIndex
        val isPast = index < activeChunkIndex
        Column(
            modifier = Modifier
                .clip(RoundedCornerShape(10.dp))
                .background(if (isActive) MaterialTheme.colorScheme.primaryContainer else Color.Transparent)
                .padding(horizontal = 12.dp, vertical = 8.dp)
        ) {
            Text(
                text = chunk.text,
                style = if (isActive) MaterialTheme.typography.headlineSmall
                        else MaterialTheme.typography.titleMedium,
                fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal,
                color = when {
                    isActive -> MaterialTheme.colorScheme.onPrimaryContainer
                    isPast -> MaterialTheme.colorScheme.onSurfaceVariant
                    else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f)
                }
            )
        }
    }
}

界面决策表:

决策 理由
静默期沿用"最近一次朗读的块",而不是显示下一块 用户此刻正在写刚读完的内容,参照物必须是刚读完的那块;显示下一块等于提前剧透,会打断书写
静默期也不显示"书写时间..."占位 占位文字会替换掉唯一有用的信息(文字本身)。书写状态改由进度行 表达:第 2/3 块 · 第 2 遍 · 书写中
已读块仍显示(不淡出) 上下文对听写很重要("刚才写的是哪句"),只靠 alpha 区分状态
未读块用 45% alpha 暗示"还没读到",但不隐藏------用户可以选择先看一眼
块内小字标"拼读遍" 英文第 2 遍是逐字母拼读,视觉上和普通朗读必须可区分

3.6 可观测性:让"生效速度"看得见

改版前用户改完设置,听写页只写"速度按 字/分钟",看不到数字。现在预览卡直接显示生效值:

复制代码
已识别英文:按英文字母/分钟速度 · 共 10 个书写单元
节拍预览
预计时长 12 秒 · 1 句 1 块
每块读 2 遍 · 遍间隔约 3.0s
书写速度:60 字母/分钟(可在设置中分别调整中英文)

实现上给 PreviewStats 加了 speedPerMinute 字段,由 cfg.speedFor(script) 填充------UI 不再自己猜该读哪个字段,和引擎用的是同一个入口。

4. 关键实现

4.1 设置项(两项,各自 6~100)

kotlin 复制代码
NumberSettingItem(
    title = "中文书写速度(字/分钟)",
    description = "每分钟能写多少个汉字,决定每块留多长书写时间",
    value = viewModel.settings.dictationChineseCharsPerMinute,
    onValueChange = viewModel::updateDictationChineseCharsPerMinute,
    range = 6..100
)
NumberSettingItem(
    title = "英文书写速度(字母/分钟)",
    description = "每分钟能写多少个字母,英文听写只按这个速度留时间",
    value = viewModel.settings.dictationEnglishCharsPerMinute,
    onValueChange = viewModel::updateDictationEnglishCharsPerMinute,
    range = 6..100
)

ViewModel 侧各存各的,不互相写:

kotlin 复制代码
fun updateDictationChineseCharsPerMinute(value: Int) {
    val valid = value.coerceIn(6, 100)
    saveSettings(settings.copy(dictationChineseCharsPerMinute = valid))
}

4.2 配置加载:Settings → 领域快照

kotlin 复制代码
private fun Settings.toDictationConfig(): DictationConfig = DictationConfig(
    charsPerMinute = dictationChineseCharsPerMinute,      // 兜底值取中文速度
    chineseCharsPerMinute = dictationChineseCharsPerMinute,
    englishCharsPerMinute = dictationEnglishCharsPerMinute,
    // ...
)

4.3 单测:把"两条设置互不串味"钉死

kotlin 复制代码
@Test
fun `中英文各用自己的速度影响时间轴`() {
    fun build(text: String, cfg: DictationConfig) = TimelineBuilder.build(text, cfg) { chunk, _ ->
        TimelineBuilder.estimateSpeakMs(chunk)
    }
    val cnFast = build("一二三四五六。", DictationConfig(chineseCharsPerMinute = 60, repeatTimes = 3))
    val cnSlow = build("一二三四五六。", DictationConfig(chineseCharsPerMinute = 10, repeatTimes = 3))
    assertTrue(cnFast.totalMs < cnSlow.totalMs)

    val enFast = build("hello world", DictationConfig(englishCharsPerMinute = 60, repeatTimes = 3))
    val enSlow = build("hello world", DictationConfig(englishCharsPerMinute = 10, repeatTimes = 3))
    assertTrue(enFast.totalMs < enSlow.totalMs)

    // 改英文速度不能影响中文时间轴
    val cnUnaffected = build(
        "一二三四五六。",
        DictationConfig(chineseCharsPerMinute = 10, englishCharsPerMinute = 100, repeatTimes = 3)
    )
    assertEquals(cnSlow.totalMs, cnUnaffected.totalMs)
}

加上"取各自的值""未设置回落""越界钳制"三条边界,一共 4 项;全量单测从 115 涨到 119。

5. 踩坑记录(共 5 个)

坑 1:统计页"听写正确率 6%",结果页却写 100%

现象 :一轮英文听写,结果页显示 应写 1 · 写对 1 · 正确率 100%,统计页的听写卡却是 听写正确率 6.3%。同一轮听写,两个界面互相打脸。

原因:分子分母量纲不同。

  • 会话表 totalChars 由 ScriptDetector.countWritable(text) 得到,英文按字母 计数(eeeeeeeeeeeapple = 16);
  • 校验回填的 correctChars 用的是校验单元数 ,而 ManualMarker.buildUnits() 对英文按空白切单词(同一个 16 字母的文本只有 1 个单元);
  • 于是 1/16 = 6.25%,而结果页按单元算 1/1 = 100%。

中文因为"单元=单字=可写字符"三者相等,一直没暴露。

解决:回填和结果页都按"可写字数"折算,用单元宽度而不是单元个数:

kotlin 复制代码
fun width(unit: ManualMarker.MarkableUnit): Int =
    ScriptDetector.countWritable(unit.text).coerceAtLeast(1)

val total = units.sumOf { width(it) }
val wrong = units.filter { it.index in wrongSet }.sumOf { width(it) }
val correct = total - wrong

日志同时打两种量纲,方便对账:

复制代码
校验完成(MANUAL): 对 1 单元/5 字 · 错 1 单元/5 字 · 正确率 50%

预防:任何"分子来自 A 集合、分母来自 B 集合"的比率,都要在字段注释里写清双方单位;英文场景必须显式跑一次"单单词文本",否则中文路径永远发现不了。

坑 2:给设置列改名,差点把老用户数据迁移没了

现象 :决定拆分速度时,第一反应是把 dictationCharsPerMinute 改名成 dictationEnglishCharsPerMinute,Settings 实体和数据库列一起改。跑起来直接报迁移校验失败(列不匹配)。

原因 :Room 的自动迁移通过比对 schemas/<version>.json 里的列名生成 SQL。把列改名,在迁移器看来是"删掉旧列 + 新增新列",而自动迁移无权重命名,只能报结构不一致。

解决 :列名保持不动,只改 Kotlin 字段名,用注解把它映射回原列:

kotlin 复制代码
@ColumnInfo(name = "dictationCharsPerMinute", defaultValue = "10")
val dictationEnglishCharsPerMinute: Int = 10,

同时新增 dictationChineseCharsPerMinute 一列,版本 9→10,只做一次加列。

预防 :把"列名是契约、字段名是内部命名"写进数据层规范;每次改实体前先看一遍 app/schemas/ 里最近一版的 JSON,确认自己改的是字段还是列。

坑 3:静默节拍里,界面根本不知道"刚读的是哪块"

现象:想实现"高亮正在读的块",但进了静默节拍高亮就消失,界面只能退化成显示"书写时间..."。

原因 :时间轴的节拍是 sealed interface:

kotlin 复制代码
sealed interface DictationBeat {
    data class Speak(val chunkIndex: Int, val repeatIndex: Int, val style: SpeakStyle, ...)
    data class Silence(val kind: Kind, ...)   // 没有 chunkIndex
}

UI 只取 currentBeat,静默节拍没有 chunkIndex,只能得到 null,于是"当前块"这个概念在静默期不存在。

解决 :把"当前块"从原始节拍改成派生状态 ------回看已播节拍里的最近一次 Speak:

kotlin 复制代码
val lastSpeakBeat = timeline.beats
    .subList(0, playbackState.beatIndex.coerceIn(0, timeline.beats.size))
    .filterIsInstance<DictationBeat.Speak>()
    .lastOrNull()
val speakingBeat = (beat as? DictationBeat.Speak) ?: lastSpeakBeat

顺带:进度行也用这个派生值,所以静默期仍能显示"第 2 遍",不会因为节拍类型切换而丢信息。

预防:节拍是"时间上发生的事",UI 要的是"此刻该显示什么"。两者别用同一个变量;凡是"界面需要的语义",都单独写一个派生层,别指望原始数据自带。

坑 4:Modifier.weight(1f) 在自定义 Composable 里编译不过

现象 :把播放页主体抽成 PlaybackBody() 后,里面给 LazyColumn 加 Modifier.weight(1f) 想让它占满剩余高度,编译报错(weight 不可用)。

原因 :weight 不是通用修饰符,是 ColumnScope / RowScope 的扩展函数。PlaybackBody 是个普通 @Composable 函数,函数体里没有 ColumnScope 接收者,自然调不到。

解决:把函数签名改成作用域扩展:

kotlin 复制代码
@Composable
private fun ColumnScope.PlaybackBody(...) { /* 这里就能用 Modifier.weight(1f) */ }

调用处仍在原来的 Column { } 里,无需改动。

预防 :Compose 的布局修饰符都是作用域绑定的(weight/align/alignByBaseline)。抽子组件时先想清楚它需要哪个作用域,签名就带上哪个作用域。

坑 5:单测连红两次,都是我自己的假设错

现象 :新写的 DictationSpeedTest 第一次跑 2 项失败:

  1. assertEquals(6, cfg.englishCharsPerMinute) 失败------传 1 进去,sanitized() 之后还是 1;
  2. assertTrue(chunks.size >= 2) 失败------"春天来了hello world。"只切出 1 块。

原因:

  1. sanitized() 里写的是 coerceIn(0, 100),本意是"保留 0 作哨兵",结果把 1 这种非法值也留下了;哨兵语义应该是"非正即 0,正数必须落在 6..100"。
  2. 切块模式是 PUNCTUATION,只有逗号/顿号才切块,这句只有一个句号 → 1 块。我按"中英混排必然多块"想当然写了断言。

解决:

kotlin 复制代码
chineseCharsPerMinute = if (chineseCharsPerMinute <= 0) 0 else chineseCharsPerMinute.coerceIn(6, 100),

测试改成"中文、英文各自构建两条时间轴比总时长",不再依赖切块数量;混排那条断言删掉,改成用纯中文、纯英文两条独立路径验证。

预防 :哨兵值要显式建模(0 = 未设置),不能靠类型系统的宽容;写断言前先用真实输入跑一遍 println 看实际值,别凭直觉写"应该至少两块"。

6. 总结

改版结果:

指标 改前 改后
速度设置项 1 项(单值,语种自适应) 2 项(中文 字/分、英文 字母/分)
同一英文文本(10 字母)时长 62 秒 / 6 遍 / 间隔 8.0s(速度 10) 12 秒 / 2 遍 / 间隔 3.0s(速度 60)
播放页静默期 显示"书写时间...",文字消失 全文常驻,最近朗读块持续高亮
进度行 只有块序号 块序号 + 遍数 + 朗读中/书写中
数据库 version 9,13 列 version 10,14 列(自动迁移,实测旧数据 100% 保留)
单测 115 项 119 项,0 失败

三条可复用的经验:

  1. 量纲是隐性接口 。totalChars、correctChars、unit.width 三个值的单位必须在命名或注释里钉死,否则"看起来都对"的两个界面会给出矛盾的百分比。
  2. 数据库列名是契约,代码字段名是内部命名 。想重命名时先分清改的是哪一层;能靠 @ColumnInfo(name=...) 解耦的,就不要动迁移。
  3. UI 需要的是派生状态,不是原始事件。节拍告诉程序"此刻在播什么",界面要的是"此刻该显示哪一块"------中间缺的那层派生逻辑,正是这次静默期高亮能成立的原因。

后续计划:静默期的剩余倒计时环(现在只有文字状态)、真机息屏续播验证、中文速度的 UI 端到端实测(模拟器输入法只能打 ASCII,本轮靠单测覆盖中文取值分支)。

相关推荐
好的佩奇1 小时前
Dart 之方法、操作符与可调用对象
android·flutter·dart
恋猫de小郭1 小时前
Flutter Impeller Vulkan 内存持续上涨,问题没想到是文字阴影
android·前端·flutter
张小姐的猫12 小时前
【MySQL】访问 & 数据流动原理
android·数据库·mysql·adb·1024程序员节
朱涛的自习室14 小时前
212 个需求、22 万行代码:Agent 接管开源项目的 19 天
android·前端·人工智能
传奇开心果19 小时前
【Flutter踩坑排雷】第3课:const 误用坑|const 错误使用引发 UI 异常与性能损耗
android·flutter
哲鲤19 小时前
AOSP思绎|Android 多媒体:一段视频为什么需要这么多服务
android
阿杰m19 小时前
Android源码第三章:Handler、Looper 与 MessageQueue——一条消息如何入队、唤醒并执行
android
海雅达手持终端PDA1 天前
巡线、深井、爆管抢修与极寒水网的硬件矩阵怎么配?海雅达水务巡检终端PDA选型指南
android·物联网·5g·集成测试·能源·政务