Operit 语音服务设置页 UI 重排实践:TTS/STT 分页、单行档案管理与低频参数折叠
Operit 语音服务设置页 UI 重排实践:TTS/STT 分页、单行档案管理与低频参数折叠
导读
本文围绕 Operit(Android AI Agent)语音服务设置页的 UI 重排改造展开,讲解如何在不破坏已发布配置数据、路由、自动保存机制与 Provider 参数契约的前提下,将原先一条长列表堆叠的 TTS/STT 配置区重构为「TTS/STT 页内 Tab 分页 + 单行配置档案管理 + 紧凑播放参数 + 可折叠低频设置」的清晰层级。读完本文,你将掌握该重排的设计约束、Compose 层组件拆分方式、底层档案数据模型与迁移投影契约,以及自动保存与 JSON 校验的源码级实现原理。
本文对应的规划文档为 docs/TODO/speech_services_settings_ui_20260822/index.md,核心实现位于 SpeechServicesSettingsScreen.kt。
一、改造背景:长列表堆叠带来的层级混乱
改造前的语音服务设置页把所有内容——TTS 完整配置区、STT 完整配置区、配置档案入口、供应商参数、清理规则和说明文字——连续堆叠在同一条长列表中。由此产生的实际问题有两个:
- 同屏滚动两个完整配置区:用户切到语音服务设置页后,需要在一个长列表里同时面对 TTS 与 STT 两套互不相关的参数,视觉负担重、定位困难;
- TTS 供应商切换后层级不清晰:高频的「语速 / 音调」播放参数与低频的「HTTP Headers / Response Pipeline / VITS options」JSON 参数没有主次区分,全都平铺在同一张卡片中;清理规则这类不常修改的配置也占据大量固定版面。
原文档将这两点归纳为「低频 JSON 参数和常用播放参数没有清晰层级」,这正是本次重排要解决的核心痛点。
二、设计目标与硬性约束
本次 UI 重排的目标不是引入新功能,而是在保持既有契约完全不变的前提下重新组织信息层级:
在不改变已发布配置数据、路由、自动保存和 Provider 参数契约的前提下,将页面压缩为 TTS/STT 分页、单行配置档案管理、紧凑播放参数和可展开的低频设置。
对应的作用域约束明确划定了改动边界:
| 层面 | 处理方式 |
|---|---|
SpeechServicesSettingsScreen.kt |
只调整 Compose 页面结构和档案入口展示 |
SpeechServicesSettingsPreferences.kt 及 Provider |
不修改数据模型和运行时契约 |
| 现有 TTS/STT 字段、档案操作、测试入口 | 全部保留 |
从源码看,这一约束被严格执行:设置页通过 SpeechServiceProfilesPreferences.kt 读取档案,通过旧的 SpeechServicesPreferences 保持运行时投影,供应商(Provider)的请求契约无需任何改动即可继续工作。也就是说,这是一次纯表现层(Compose UI)重构,数据层与运行时完全冻结。
三、重排后的四层页面结构
在 SpeechServicesSettingsScreen.kt 中,页面根容器仍是 LazyColumn,但内部按 selectedTabIndex 条件渲染,整体呈现四层结构:
- 顶部 Tab 分页:
SpeechServicesModeTabs(源码位置)通过TabRow+ 两个Tab实现「TTS / STT」切换,selectedTabIndex == 0显示 TTS,== 1显示 STT; - 单行配置档案管理栏:
SpeechProfileManagementBar(L2523-L2564)只渲染当前 Tab 对应的档案入口; - 紧凑播放参数区:语速与音调两个 Slider 合并进同一 Row;
- 可折叠低频设置:TTS 清理规则与 HTTP 低频参数通过
AnimatedVisibility按需展开。
3.1 TTS / STT 页内 Tab 分页
@Composable
private fun SpeechServicesModeTabs(
selectedTabIndex: Int,
onTabSelected: (Int) -> Unit,
) {
TabRow(selectedTabIndex = selectedTabIndex) {
Tab(
selected = selectedTabIndex == 0,
onClick = { onTabSelected(0) },
text = { Text(stringResource(R.string.speech_services_info_tts_title)) },
)
Tab(
selected = selectedTabIndex == 1,
onClick = { onTabSelected(1) },
text = { Text(stringResource(R.string.speech_services_info_stt_title)) },
)
}
}
TTS 配置区(selectedTabIndex == 0 时渲染,L458)与 STT 配置区(selectedTabIndex == 1 时渲染,L2101)互斥出现,用户不再需要为查看 STT 参数而滚动经过整个 TTS 配置区。页面底部的说明文字(speech_services_info_tts_desc / speech_services_info_stt_desc)和 TTS 试听按钮(OutlinedButton,跳转 onNavigateToTextToSpeech)也会跟随当前 Tab 切换显示。
3.2 单行配置档案管理
档案入口从原先的长卡片压缩为一行紧凑栏:左侧显示「当前档案」标签与档案名,右侧提供**新建(Add)与重命名(Edit)**两个图标按钮;点击档案名区域展开 DropdownMenu 列出全部档案,非当前档案在菜单项右侧带删除按钮。
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(text = title, style = MaterialTheme.typography.titleSmall, ...)
IconButton(onClick = onCreate) { Icon(Icons.Default.Add, ...) }
IconButton(onClick = onRename, enabled = profiles.any { it.id == activeProfileId }) {
Icon(Icons.Default.Edit, ...)
}
}
关键交互规则(SpeechProfileSelector,L2569-L2681):
- 当前档案不可删除:只有非活动档案的菜单项才会出现删除图标,且底层
deleteTtsProfile/deleteSttProfile会通过check(preferences[CURRENT_TTS_PROFILE_ID] != id)二次兜底; - 切换档案即激活:点击任一档案调用
selectTtsProfile/selectSttProfile,随后VoiceServiceFactory.resetInstance()/SpeechServiceFactory.resetInstance()让新档案立即生效; - 新建/重命名走对话框:
SpeechProfileNameDialog收集档案名(非空校验后确认),新建以当前档案为模板复制生成,重命名仅更新name字段; - 删除需二次确认:
SpeechProfileDeleteDialog弹出确认文案,确认后才真正从 DataStore 移除。
3.3 语速与音调合并的紧凑播放参数区
重排后,语速与音调两个 Slider 不再各占一整行,而是并排放在同一 Row(Arrangement.spacedBy(12.dp) + weight(1f)),每个 Slider 上方用 bodySmall 文本实时显示当前值:
- 语速
speechRate:Slider 取值范围0.5f..2.0f,steps = 5; - 音调
pitch:同样取值0.5f..2.0f,steps = 5。
该区域位于服务类型选择下拉框之下、供应商专属配置区之上,属于所有 TTS 类型共享的「播放参数」层(L542-L572)。
3.4 可折叠的低频设置
低频设置通过两个布尔状态控制展开:
var ttsCleanerExpanded by remember { mutableStateOf(false) }
var ttsHttpAdvancedExpanded by remember { mutableStateOf(false) }
TTS 清理规则折叠区(L697-L817):标题行显示 清理规则 (N) 与折叠箭头,展开后按索引列出每条正则输入框(可单独删除),底部提供「添加」按钮与模板下拉菜单,内置五个常用正则模板:
| 模板 | 正则 |
|---|---|
星号强调 *xxx* |
\*[^*]+\* |
双星号 **xxx** |
\*\*[^*]+\*\* |
半角括号 (xxx) |
\([^)]+\) |
全角括号 (xxx) |
([^)]+) |
XML 标签 <xxx> |
<[^>]+> |
HTTP 低频参数折叠区(L851-L1009):仅当 TTS 服务类型为 HTTP_TTS 时出现,标题为「高级设置」;展开后包含:
headers:JSON 格式请求头,输入时实时Json.decodeFromString<Map<String, String>>校验,非法即显示错误并阻止自动保存;httpMethod:GET / POST 二选一下拉;contentType:默认application/json;requestBody:POST 时显示的请求体模板(支持{text}占位符);responsePipeline:响应解析流水线 JSON 数组,同样实时校验(HttpTtsResponsePipelineStep.parseList),非法 JSON 时保存被拦截。
折叠机制统一使用 AnimatedVisibility 配合 KeyboardArrowUp/KeyboardArrowDown 箭头图标,展开/收起有平滑动画,且折叠状态仅存在于本次 Composable 生命周期内,不写入配置数据。
四、底层契约:档案模型、服务类型与自动保存
4.1 档案数据模型
重排后的设置页直接消费 SpeechServiceProfilesPreferences.kt 中的两个 @Serializable 档案模型:
@Serializable
data class TtsProfile(
val id: String,
val name: String,
val serviceType: VoiceServiceFactory.VoiceServiceType,
val httpConfig: SpeechServicesPreferences.TtsHttpConfig,
val vitsConfig: SpeechServicesPreferences.VitsTtsPackageConfig,
val cleanerRegexs: List<String>,
val speechRate: Float,
val pitch: Float,
val createdAt: Long,
val updatedAt: Long,
)
@Serializable
data class SttProfile(
val id: String,
val name: String,
val serviceType: SpeechServiceFactory.SpeechServiceType,
val httpConfig: SpeechServicesPreferences.SttHttpConfig,
val createdAt: Long,
val updatedAt: Long,
)
其中 TtsHttpConfig、VitsTtsPackageConfig、SttHttpConfig 定义在 SpeechServicesPreferences.kt(L36-L61),本次重排未改动这些字段,印证了「不修改数据模型」的约束:
TtsHttpConfig:urlTemplate、apiKey、headers、httpMethod(默认"GET")、requestBody、contentType(默认"application/json")、localeTag、voiceId、modelName、responsePipeline;VitsTtsPackageConfig:packagePath、speakerId、options(Map<String, String>);SttHttpConfig:endpointUrl、apiKey、modelName。
4.2 服务类型枚举
TTS 侧(VoiceServiceFactory.kt)支持 9 种类型:SIMPLE_TTS(Android 系统 TTS)、HTTP_TTS(通用 HTTP)、OPENAI_WS_TTS(OpenAI Realtime WebSocket)、SILICONFLOW_TTS、MINIMAX_TTS、MIMO_TTS、DOUBAO_TTS、OPENAI_TTS、VITS_TTS。每种类型在设置页有独立的 AnimatedVisibility 配置区(如 VITS 的 packagePath/speakerId/options、SiliconFlow 的模型与预设音色下拉、豆包切换时自动填充默认端点与音色 ID 等)。
STT 侧(SpeechServiceFactory.kt)支持 3 种类型:SHERPA_NCNN(本地识别)、OPENAI_STT、DEEPGRAM_STT;后两者共享「端点 URL + API Key + 模型名」三字段表单。
4.3 500ms 防抖自动保存与 JSON 校验闸门
重排保留并沿用了既有的自动保存机制:所有输入状态通过 LaunchedEffect 监听,任一字段变化即进入保存流程(L220-L347):
- 计算
hasPendingChanges(对全部 TTS/STT 输入字段逐一比较); - 无变化直接返回;若当前为
HTTP_TTS且 headers/responsePipeline JSON 非法,或为VITS_TTS且 options JSON 非法,同样直接返回,不落盘; kotlinx.coroutines.delay(500)防抖后二次检查(用户可能在等待期间改回原值);- 解析 JSON 并组装
TtsHttpConfig/VitsTtsPackageConfig/SttHttpConfig; - 调用
profilePrefs.updateTtsProfile/updateSttProfile写入 DataStore; - 调用
VoiceServiceFactory.resetInstance()与SpeechServiceFactory.resetInstance(),使运行时立即采用新配置。
写入失败(如档案名非法、语速/音调非正数)会被捕获并写入 ttsProfileError/sttProfileError,通过页面底部 SnackbarHost 弹出错误提示。
五、数据兼容:档案迁移与旧偏好投影
虽然本次 UI 重排本身不涉及迁移,但其依赖的档案机制建立在一次性的旧数据迁移之上(详见配套文档 docs/TODO/speech_service_profiles_20260810/index.md),理解这条链路有助于解释「为何 UI 可以放心重构」:
- 首次启动时,
schemaMigration把旧SpeechServicesPreferences中的 TTS/STT 当前配置分别生成固定 ID 为legacy-tts-profile、legacy-stt-profile的档案(SpeechServiceProfilesPreferences.kt中LEGACY_TTS_PROFILE_ID/LEGACY_STT_PROFILE_ID),只执行一次,已有档案不重复创建; - 每次档案的创建、更新、切换,都会通过
projectTtsProfile/projectSttProfile把活跃档案写回旧偏好接口,作为兼容投影供已有 Provider 读取; - 因此本次 UI 重排可以放心只动 Compose 页面结构——Provider 与数据模型看到的输入始终与活跃档案保持一致,参数契约从迁移期到重排期完全未变。
六、改造结果与验收对照
对照原文档「结果」一节,重排后的实现逐条落地:
- TTS 与 STT 通过页内 Tab 切换:
SpeechServicesModeTabs使两套完整配置区互斥显示,避免同屏滚动; - 当前 Tab 只显示对应档案入口:
SpeechProfileManagementBar按selectedTabIndex分流到 TTS/STT 各自的SpeechProfileSelector; - 语速与音调合并到同一播放参数区域:两个
0.5f..2.0f的 Slider 并排于同一 Row; - TTS 清理规则与 HTTP 低频参数支持折叠:
ttsCleanerExpanded/ttsHttpAdvancedExpanded两个折叠状态 +AnimatedVisibility实现按需展开。
验收要点:所有 TTS/STT 字段、档案的新建/重命名/选择/删除操作、以及测试入口(TTS 试听按钮)在重排后均原样保留;配置数据存储格式与运行时 Provider 契约未发生任何变化,因此已发布用户升级后无需重新配置即可无缝衔接新 UI。
七、小结与参考路径
语音服务设置页 UI 重排是一次「表现层重构」的典型实践:用 Tab 分页切割长列表、用单行档案栏压缩管理入口、用折叠收纳低频参数、用并排 Slider 合并播放参数,在不触碰数据模型、路由、自动保存与 Provider 契约的前提下显著改善信息层级。对于需要继续深入阅读的开发者,可按以下路径追踪:
- 规划文档:docs/TODO/speech_services_settings_ui_20260822/index.md
- 页面实现:SpeechServicesSettingsScreen.kt(Tab 分页 L2500-L2516、档案管理栏 L2523-L2564、自动保存 L220-L347)
- 档案存储与迁移:SpeechServiceProfilesPreferences.kt
- 旧偏好模型:SpeechServicesPreferences.kt
- 服务工厂枚举:VoiceServiceFactory.kt、SpeechServiceFactory.kt
- 档案机制配套文档:docs/TODO/speech_service_profiles_20260810/index.md