一个"书写速度 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 块,用户也无法判断"现在读的是第几句、还剩多少"。
目标:
- 中文、英文各一项速度,互不影响,各自 6~100;
- 播放页全文常驻,正在朗读的块高亮,静默期保持这块高亮(不切走);
- 老用户数据零丢失(已写好的设置、会话记录不能因为改结构而丢);
- 不回归:已有 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),
// ...
)
}
设计要点:
- 0 表示"未设置"而不是"默认 10" 。听写会话会把配置序列化成 JSON 存进
dictation_session.configJson,v9 存下的 JSON 里只有charsPerMinute一个速度字段;反序列化成 v10 的类时,新增两个字段会取默认值 0,于是自动回落到旧的那个值。用 0 做哨兵,是为了让"历史数据"和"用户主动设了 6"能区分开------如果默认写成 10,老会话回放时就会被悄悄改成 10。 - 兜底值仍保留
charsPerMinute。它还有第二重身份:旧会话回放时的时间轴重建。 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 项失败:
assertEquals(6, cfg.englishCharsPerMinute)失败------传 1 进去,sanitized()之后还是 1;assertTrue(chunks.size >= 2)失败------"春天来了hello world。"只切出 1 块。
原因:
sanitized()里写的是coerceIn(0, 100),本意是"保留 0 作哨兵",结果把 1 这种非法值也留下了;哨兵语义应该是"非正即 0,正数必须落在 6..100"。- 切块模式是
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 失败 |
三条可复用的经验:
- 量纲是隐性接口 。
totalChars、correctChars、unit.width三个值的单位必须在命名或注释里钉死,否则"看起来都对"的两个界面会给出矛盾的百分比。 - 数据库列名是契约,代码字段名是内部命名 。想重命名时先分清改的是哪一层;能靠
@ColumnInfo(name=...)解耦的,就不要动迁移。 - UI 需要的是派生状态,不是原始事件。节拍告诉程序"此刻在播什么",界面要的是"此刻该显示哪一块"------中间缺的那层派生逻辑,正是这次静默期高亮能成立的原因。
后续计划:静默期的剩余倒计时环(现在只有文字状态)、真机息屏续播验证、中文速度的 UI 端到端实测(模拟器输入法只能打 ASCII,本轮靠单测覆盖中文取值分支)。