Operit 语音服务设置页 UI 重排实践:TTS/STT 分页、单行档案管理与低频参数折叠

原创2026-09-27 23:59:51243 阅读
文章标签:AI Agent人工智能大模型AI 应用工具调用本地部署MCP ClientsAgent 记忆GUI 自动化

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 完整配置区、配置档案入口、供应商参数、清理规则和说明文字——连续堆叠在同一条长列表中。由此产生的实际问题有两个:

  1. 同屏滚动两个完整配置区:用户切到语音服务设置页后,需要在一个长列表里同时面对 TTS 与 STT 两套互不相关的参数,视觉负担重、定位困难;
  2. 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 条件渲染,整体呈现四层结构:

  1. 顶部 Tab 分页:SpeechServicesModeTabs(源码位置)通过 TabRow + 两个 Tab 实现「TTS / STT」切换,selectedTabIndex == 0 显示 TTS,== 1 显示 STT;
  2. 单行配置档案管理栏:SpeechProfileManagementBar(L2523-L2564)只渲染当前 Tab 对应的档案入口;
  3. 紧凑播放参数区:语速与音调两个 Slider 合并进同一 Row;
  4. 可折叠低频设置: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):

  1. 计算 hasPendingChanges(对全部 TTS/STT 输入字段逐一比较);
  2. 无变化直接返回;若当前为 HTTP_TTS 且 headers/responsePipeline JSON 非法,或为 VITS_TTS 且 options JSON 非法,同样直接返回,不落盘;
  3. kotlinx.coroutines.delay(500) 防抖后二次检查(用户可能在等待期间改回原值);
  4. 解析 JSON 并组装 TtsHttpConfig / VitsTtsPackageConfig / SttHttpConfig;
  5. 调用 profilePrefs.updateTtsProfile / updateSttProfile 写入 DataStore;
  6. 调用 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 与数据模型看到的输入始终与活跃档案保持一致,参数契约从迁移期到重排期完全未变。

六、改造结果与验收对照

对照原文档「结果」一节,重排后的实现逐条落地:

  1. TTS 与 STT 通过页内 Tab 切换:SpeechServicesModeTabs 使两套完整配置区互斥显示,避免同屏滚动;
  2. 当前 Tab 只显示对应档案入口:SpeechProfileManagementBar 按 selectedTabIndex 分流到 TTS/STT 各自的 SpeechProfileSelector;
  3. 语速与音调合并到同一播放参数区域:两个 0.5f..2.0f 的 Slider 并排于同一 Row;
  4. TTS 清理规则与 HTTP 低频参数支持折叠:ttsCleanerExpanded / ttsHttpAdvancedExpanded 两个折叠状态 + AnimatedVisibility 实现按需展开。

验收要点:所有 TTS/STT 字段、档案的新建/重命名/选择/删除操作、以及测试入口(TTS 试听按钮)在重排后均原样保留;配置数据存储格式与运行时 Provider 契约未发生任何变化,因此已发布用户升级后无需重新配置即可无缝衔接新 UI。

七、小结与参考路径

语音服务设置页 UI 重排是一次「表现层重构」的典型实践:用 Tab 分页切割长列表、用单行档案栏压缩管理入口、用折叠收纳低频参数、用并排 Slider 合并播放参数,在不触碰数据模型、路由、自动保存与 Provider 契约的前提下显著改善信息层级。对于需要继续深入阅读的开发者,可按以下路径追踪:

登录后查看全文
Operit