Operit 角色主题持久化改造:目标作用域快照、串行化写入与编辑器会话设计
Operit 角色主题持久化改造:目标作用域快照、串行化写入与编辑器会话设计
导读
本文基于 Operit 仓库中 issue_782_theme_persistence 系列设计文档,完整讲解"角色卡 / 群组主题持久化"这一从两阶段保存到编辑器会话的演进过程。你将掌握:主题投影与目标作用域的关系、旧版全局主题的一次性迁移策略、用互斥协调器把"目标激活 + 主题写入"串行化以避免跨目标误写,以及 WebChat 按聊天元数据解析主题快照的实现方案。文章同时给出 UserPreferencesManager.kt、ActivePromptManager.kt 等源码级证据,供读者按图索骥。
一、问题背景:两阶段保存与目标切换竞态
Issue 782 要解决的核心问题,在于旧版主题编辑路径是一条两阶段流水线:
- 编辑器先把改动写入共享投影键(全局 Android 主题使用的键名);
- 随后在另一个独立协程中,把共享键的值复制到当前目标(角色卡或群组)的专属前缀下。
这条路径存在两个致命缺陷:
- 跨目标误写:在"写共享键"与"复制到目标前缀"两次 DataStore 事务之间,用户可以切换角色卡。若切换发生在两次操作的间隙,前一个目标的投影就可能被存进后一个目标的快照里。原文档明确记录了该竞态:
A target switch between those operations can save one target's projection into another target's snapshot. - 编辑非活动目标不可能:编辑器只绑定当前活动的 chat prompt,控件改动立即写入共享投影并再复制一份到活动目标,因此无法编辑非活动目标,也做不到"先改、后存、可放弃"的草稿体验。
后续的 issue_782_theme_editor_drafts/index.md 是这一轮持久化工作的正式实现(status: implementation_complete),其中 issue_782_theme_persistence 被标记为 superseded,其"两阶段保存路径与验证契约"由编辑器会话方案取代。本文以持久化设计文档为主线,结合最终源码说明完整落地形态。
二、目标作用域模型:角色卡与群组的主题前缀
2.1 前缀命名规则
主题数据并不只存在一套全局键里,而是为每个目标(角色卡、群组、默认卡)分配独立前缀。从 UserPreferencesManager.kt 可以看到:
private fun getCharacterCardThemePrefix(characterCardId: String): String =
"character_card_theme_${characterCardId}_"
private fun getCharacterGroupThemePrefix(characterGroupId: String): String =
"character_group_theme_${characterGroupId}_"
即一个角色卡的主题键形如 character_card_theme_<cardId>_THEME_MODE,一个群组的主题键形如 character_group_theme_<groupId>_BUBBLE_AI_BUBBLE_COLOR。默认角色卡的 ID 在 CharacterCardManager.kt 中定义为常量 DEFAULT_CHARACTER_CARD_ID = "default_character"。
2.2 主题键按原始类型分组维护
这些键被严格按 DataStore 原始类型划分为四组,分别由四个函数维护(UserPreferencesManager.kt):
| 分组 | 代表性键 | 数据类型 |
|---|---|---|
| String 键 | THEME_MODE、BACKGROUND_IMAGE_URI、CHAT_STYLE、INPUT_STYLE、FONT_TYPE、KEY_CUSTOM_USER_AVATAR_URI、KEY_CUSTOM_AI_AVATAR_URI、KEY_CUSTOM_CHAT_TITLE、气泡图片/字体相关键 |
Preferences.Key<String> |
| Boolean 键 | USE_SYSTEM_THEME、USE_CUSTOM_COLORS、TOOLBAR_TRANSPARENT、NAVIGATION_DRAWER_WATER_GLASS、CHAT_INPUT_LIQUID_GLASS、BUBBLE_AI_BUBBLE_WATER_GLASS、KEY_SHOW_THINKING_PROCESS、KEY_SHOW_TIMESTAMP 等显示开关 |
Preferences.Key<Boolean> |
| Int 键 | CUSTOM_PRIMARY_COLOR、CUSTOM_SECONDARY_COLOR、CUSTOM_APP_BAR_COLOR、CUSTOM_STATUS_BAR_COLOR、气泡文字/背景颜色 |
Preferences.Key<Int> |
| Float 键 | BACKGROUND_IMAGE_OPACITY、BACKGROUND_BLUR_RADIUS、KEY_AVATAR_CORNER_RADIUS、FONT_SCALE、气泡图片裁剪/平铺/缩放参数 |
Preferences.Key<Float> |
注意
KEY_CUSTOM_AI_AVATAR_URI(AI 头像)与KEY_CUSTOM_CHAT_TITLE(自定义聊天标题)虽属于 String 键,但在设计中是目标展示元数据而非视觉主题数据——这一区分直接决定后面"重置"与"删除"的边界。
2.3 快照读取:前缀为空则回落到应用默认值
readThemePreferenceValues(preferences, prefix) 的做法是:先用 ThemePreferenceValues.defaultVisual() 建立默认值,再逐一用 ${prefix}${key.name} 覆盖(UserPreferencesManager.kt)。因此:
- 目标前缀下没有任何键时,读出的就是应用默认主题;
- 目标前缀下只覆盖了少数键时,其余字段自动取默认值。
这也是"无已存主题的卡必须显示应用默认值、且不得保留前一个目标的投影"这一验收标准的实现基础。另外该方法还兼容旧版"仅存水平重复值、无垂直重复值"的数据(copyLegacyRepeatYValue),把水平值回落为垂直值,属于兼容性边界的一部分。
三、投影与迁移:默认卡与旧版全局主题
3.1 旧行为
旧版激活角色卡时,如果该卡的 scoped 前缀为空,会跳过投影,于是共享键继续保留上一个目标的主题——"默认切换到 A 再切回,A 看到的是别人的主题"正是由此产生。此外旧安装可能只有一套全局主题、却没有默认卡的快照。
3.2 新行为:空前缀也执行投影
新设计下,激活每一个选中的卡都必须执行前缀投影(1_ProjectionAndMigration.md):
- 空前缀同样被应用:激活默认卡(无已存主题)时,用空前缀"清空"活动投影,Compose 因此解析到应用默认值;
- 激活时清除投影中目标缺失的键:
copyThemeValues(preferences, sourcePrefix, targetPrefix, clearMissingTargetValues = false)中clearMissingTargetValues参数控制是否删除目标中不存在的键(UserPreferencesManager.kt),激活路径会清理掉"共享投影中存在、但所选作用域中没有"的键; - 常规保存保留独立元数据:普通保存不清理
KEY_CUSTOM_AI_AVATAR_URI、KEY_CUSTOM_CHAT_TITLE这类目标级元数据,避免误删用户单独维护的信息。
3.3 一次性迁移:仅当归属明确
旧版全局主题只在"确实属于默认卡"时才迁移为默认卡的 scoped 快照,且只执行一次。迁移决策由 ThemeScopeMigrationPolicy.kt 独立成类:
internal object ThemeScopeMigrationPolicy {
fun shouldCopyLegacyThemeToDefaultCharacter(
migrationCompleted: Boolean,
activeCharacterCardId: String?,
defaultCharacterId: String,
hasDefaultCharacterTheme: Boolean,
hasAnyScopedTheme: Boolean,
defaultCharacterWasCreated: Boolean,
): Boolean {
if (migrationCompleted || hasDefaultCharacterTheme || hasAnyScopedTheme) {
return false
}
return defaultCharacterWasCreated || activeCharacterCardId == defaultCharacterId
}
}
判定规则可概括为:已迁移过、默认卡已有主题、或已存在任何 scoped 主题时绝不迁移;只有当前激活卡是默认卡、或默认卡刚被创建(此时旧全局主题只可能属于它)才允许把无前缀的全局主题复制到 default_character 前缀下。迁移入口在 UserPreferencesManager.kt 的 migrateLegacyDefaultCharacterThemeIfEligible,迁移完成后写入 CHARACTER_THEME_DEFAULT_MIGRATION_COMPLETED = true 标记防止重复执行。
该策略有完整的单元测试覆盖(ThemeScopeMigrationPolicyTest.kt),包括五个用例:活动默认卡迁移、存在 scoped 数据阻止迁移、新创建默认卡迁移、未知活动卡不迁移、迁移完成后不再执行。
四、目标序列化:共享互斥的写入协调器
4.1 从"两协程各干各的"到"一个协调器"
旧实现中,设置编辑器为全局偏好更新启动一个协程、为 scoped 快照再调度另一个协程,目标激活可以插在两者之间执行。修复方案是引入唯一协调器(ThemeTargetOperationCoordinator),让 prompt 切换、目标绑定保存、目标绑定重置全部走同一把互斥锁;在持有与激活相同的 mutex 时校验捕获的目标,再同步完成共享更新与 scoped 快照写入。其实现非常精简(ThemeTargetOperationCoordinator.kt):
internal class ThemeTargetOperationCoordinator {
private val mutex = Mutex()
suspend fun <T> runTransition(action: suspend () -> T): T {
return mutex.withLock {
action()
}
}
}
4.2 ActivePromptManager:对外统一入口
ActivePromptManager.kt 是角色/群组激活与主题写入的门面,内部持有 themeOperations = ThemeTargetOperationCoordinator()(L17),并派生两条核心数据流:
activePromptFlow(L19-L29):由observeActiveCharacterGroupId()与observeActiveCharacterCardId()combine 而来,群组优先、其次角色卡、否则回落到默认卡;activeThemePreferenceSnapshotFlow(L32-L47):对活动 prompt 做flatMapLatest,为该目标实时观测主题快照,目标切换后自动改读新目标的 scoped 数据。
所有变更类操作都包在 runTransition 里,与激活共用一个 mutex:
suspend fun setActivePrompt(prompt: ActivePrompt) { // L51-64
themeOperations.runTransition {
when (prompt) {
is ActivePrompt.CharacterGroup -> {
characterGroupCardManager.setActiveCharacterGroupCard(prompt.id)
characterCardManager.clearActiveCharacterCard()
}
is ActivePrompt.CharacterCard -> {
characterCardManager.setActiveCharacterCard(prompt.id)
characterGroupCardManager.setActiveCharacterGroupCard(null)
}
}
}
}
suspend fun mutateActiveThemeForPrompt(target: ActivePrompt, transform: ...) { // L70-81
themeOperations.runTransition {
if (getActivePrompt() != target) return@runTransition // 目标校验
userPreferencesManager.mutateThemeForPrompt(target = target, transform = transform)
}
}
suspend fun commitThemeDraft(target: ActivePrompt, values: ThemePreferenceValues) { // L83-93
themeOperations.runTransition {
userPreferencesManager.replaceThemeForPrompt(target = target, values = values)
}
}
suspend fun resetThemeDraft(target: ActivePrompt, values: ThemePreferenceValues) { // L95-105
themeOperations.runTransition {
userPreferencesManager.resetVisualThemeForPrompt(target = target, values = values)
}
}
关键点在于 mutateActiveThemeForPrompt 中持有锁的同时校验当前活动目标(getActivePrompt() != target 则直接放弃),从而保证"一次编辑只属于发起它的目标,已离开页面的事件不会写进新目标"——这正是原文档期望结果 An edit belongs only to the target that initiated it 的落点。此外 saveAiAvatarForPrompt、saveCustomChatTitleForPrompt 也走同一锁路径写入目标元数据。
4.3 数据层:一次 DataStore 事务完成三件事
底层写操作位于 UserPreferencesManager.kt,每个方法都在单个 userPreferencesDataStore.edit { ... } 事务内完成:
replaceThemeForPrompt:整段替换——先writeVisualThemeValues写入全部视觉键,再writeThemeTargetMetadata写入 AI 头像与自定义聊天标题;mutateThemeForPrompt:读取目标前缀现值 → 变换 → 同事务写回视觉键与元数据;resetVisualThemeForPrompt:clearVisualThemeValues只删除视觉键(String 组中排除KEY_CUSTOM_AI_AVATAR_URI与KEY_CUSTOM_CHAT_TITLE,见isVisualThemeStringKey,L914-L937),随后在同一事务中写回目标元数据。
也就是说:保存不会让共享投影与目标作用域不同步;重置只改视觉配置;而删除角色/群组时才走 deleteCharacterCardTheme / deleteCharacterGroupTheme(deleteThemeByPrefix,L1096-L1111)把该目标全部键清掉。
五、编辑器会话:从"边改边存"到"一张草稿一次保存"
5.1 会话取代"立即保存"
持久化文档最终被 编辑器会话设计 取代。其核心变化是:主题控件不再直接调 saveThemeSettingsWithCharacterCard 立即落库,而是绑定一个屏幕持有的编辑器会话(editor session):
- 会话由"选中目标的主题快照"初始化,持有
values(值流)、baseline(基线)、dirty/reset状态与staged assets(暂存文件); - 各主题分区读取同一个
values StateFlow并同步更新内存草稿; - 最近颜色等全局项仍委托给全局存储,但角色绑定主题键不写入,直到用户显式提交草稿;
- 保存前先捕获源目标(
val target = state.target),再做异步工作;reset在保存前只是"草稿重置",真正清键发生在提交时。
见 ThemeSettingsContentEditor.kt 的 saveCurrentDraft:
fun saveCurrentDraft() {
val state = editorState ?: return
val draft = state.session
if (isSaving) return
val target = state.target // 先捕获目标
val savedValues = draft.currentValues
val resetRequested = draft.isResetRequested
isSaving = true
draft.beginSave(savedValues)
scope.launch {
try {
if (resetRequested) {
activePromptManager.resetThemeDraft(target, savedValues)
} else {
activePromptManager.commitThemeDraft(target, savedValues)
}
draft.markSaved(savedValues)
...
} catch (e: CancellationException) {
draft.cancelSave(); throw e
} catch (e: Exception) {
draft.cancelSave() // 失败回滚到草稿态
...
} finally { isSaving = false }
}
}
失败或取消时 cancelSave() 让草稿回到可继续编辑的状态;保存成功或草稿被放弃时,暂存的 staged 文件才被清理。
5.2 目标切换与离开守卫
- 目标选择器(
ThemeSettingsTargetSelector)在 tab 上方以紧凑卡片展示默认角色、非默认卡与群组。接受选择时调用ActivePromptManager.setActivePrompt:激活角色并投影其已存主题,但不切换/创建聊天历史(without invoking chat-history auto-switch behavior,见 6_CompactTargetSwitchAndCleanup.md)。 - 脏草稿的三选一:切换目标时若当前草稿有未保存修改,弹出保存 / 放弃 / 取消,
ThemeEditorPendingAction(SelectTarget/ActiveTargetChanged/LeaveScreen)串行处理,任何激活都发生在确认完成之后(ThemeSettingsContentEditor.kt)。 - 路由离开守卫:
RegisterRouteBackGuard在pendingAction非空或有脏草稿时挂起suspendCancellableCoroutine,等待用户确认再放行(ThemeSettingsContentEditor.kt)。配套设计 4_RouteLeaveGuard.md 要求OperitApp中所有AppRouterState.navigate、resetTo、pop入口统一经过同一个挂起守卫,使返回键、抽屉项、快捷方式与外部路由请求都触发同一个保存/放弃/取消对话框。
六、WebChat 主题绑定:按请求的聊天解析目标
6.1 旧行为与问题
GET /chats/{id}/theme 只确认聊天存在,却从活动 prompt 解析主题;WebChat 的多个字段又直接读共享 Android flow,导致响应字段可能来自与返回快照不同的目标。
6.2 新行为:从聊天元数据解析
新实现中,主题解析不再依赖活动 prompt,而是按请求的聊天元数据决定目标(WebChatHttpBridge.kt):
private suspend fun resolveThemePreferenceSnapshot(chat: ChatHistory?): ThemePreferenceSnapshot {
val groupId = chat?.characterGroupId?.trim()?.takeIf { it.isNotBlank() }
if (groupId != null) {
return userPreferencesManager.resolveThemePreferenceSnapshot(characterGroupId = groupId)
}
val cardName = chat?.characterCardName?.trim()?.takeIf { it.isNotBlank() }
if (cardName != null) {
val matchingCard = characterCardManager.findCharacterCardByName(cardName)
if (matchingCard != null) {
return userPreferencesManager.resolveThemePreferenceSnapshot(characterCardId = matchingCard.id)
}
}
return userPreferencesManager.resolveThemePreferenceSnapshot(
characterCardId = CharacterCardManager.DEFAULT_CHARACTER_CARD_ID,
)
}
解析优先级:群组 ID > 角色卡名绑定 > 默认卡。resolveThemePreferenceSnapshot(UserPreferencesManager.kt)与 observeThemePreferenceSnapshot(L1205-L1236)都要求显式给出卡或群组目标,从目标前缀一次性读出完整 ThemePreferenceSnapshot;WebChat 响应中的所有字段(含气泡玻璃效果 BUBBLE_AI_BUBBLE_WATER_GLASS 等、字体开关)都从同一份快照映射,结构化渲染也使用同一解析结果。
6.3 已知数据约束
现有聊天记录只保存角色卡名称(characterCardName),不保存稳定卡 ID;因此重名角色卡在 chat schema 引入稳定卡 ID 之前无法唯一解析主题。这是文档明确记录、尚未消除的约束,也是后续迁移的候选方向。
七、验证与兼容性边界
7.1 自动化与手动验收
设计文档给出三层验证:
- 自动化覆盖(3_Verification.md):prompt 切换与目标写入共用串行协调器;编辑器提交捕获并写入显式目标快照;旧版迁移仅在默认卡归属无歧义时复制全局数据。策略逻辑由 ThemeScopeMigrationPolicyTest.kt 的 5 个用例落地。
- 手动验收:配置默认卡并创建 A → 激活 A 修改若干主题值 → 切回默认卡再切回 A → 确认 A 与默认卡各自保留自己的值;反复在切换同时改主题值,确认任一目标都不会继承另一目标的值。
- 编辑器会话版静态检查(5_Verification.md):确认所有写入路径只改编辑器会话而非持久化 manager;目标捕获发生在异步工作之前;实体清理区分"删除目标"与"视觉主题重置";WebChat 主题与结构化渲染都从请求的聊天解析;
OperitApp的所有路由变更都经过离开守卫;git diff --check通过。文档同时注明 Gradle、lint、单元测试与构建在未显式请求时未运行,xmllint不可用时跳过独立 XML 解析。
7.2 兼容性边界
为不破坏已发布数据,以下内容保持原样(见 6_CompactTargetSwitchAndCleanup.md 的 Compatibility Boundary):已发布的角色卡/群组主题前缀、已持久化的主题键名、旧版默认主题迁移逻辑、旧版垂直 repeat 值兼容、WebChat 响应字段,以及既有聊天绑定解析。因此升级后:
- 已有的 scoped 角色卡/群组主题保持原样;
- 明确归属于默认卡、且此前从未有 scoped 主题的旧全局主题,在升级后被一次性迁移进默认卡前缀;
- 任何目标都不会再显示"别人的过期投影"。
八、从源码结构看整体调用链
将各部件串联起来,主题持久化在最终实现中的调用链如下:
ThemeSettingsContentEditor (Compose 屏幕)
└─ ThemeEditorSession(草稿:values / baseline / dirty / staged assets)
└─ saveCurrentDraft / reset(先捕获 target)
└─ ActivePromptManager
├─ commitThemeDraft / resetThemeDraft / mutateActiveThemeForPrompt
└─ ThemeTargetOperationCoordinator(Mutex 串行化,含 setActivePrompt)
└─ UserPreferencesManager
├─ replaceThemeForPrompt / mutateThemeForPrompt / resetVisualThemeForPrompt
│ └─ 单次 DataStore edit:视觉键 + 目标元数据
├─ observeThemePreferenceSnapshot / resolveThemePreferenceSnapshot
│ └─ 按 character_card_theme_{id}_ / character_group_theme_{id}_ 前缀读取
└─ migrateLegacyDefaultCharacterThemeIfEligible
└─ ThemeScopeMigrationPolicy(一次性、归属明确才迁移)
WebChatHttpBridge
└─ resolveThemePreferenceSnapshot(chat):group id > card name > default_character
从代码结构可以推断,ThemeTargetOperationCoordinator 是全局唯一的串行化闸门:无论是用户点击目标选择器触发的 setActivePrompt,还是编辑器提交触发的 commitThemeDraft,都进入同一把 Mutex,从根上排除了"激活发生在保存两阶段之间"的竞态窗口。
结语
Issue 782 的持久化改造本质上是把"共享投影 + 延迟复制"的松散模型,收敛为"目标作用域快照 + 串行事务 + 显式提交"的严谨模型:空前缀投影保证无主题目标回落默认值;一次性迁移守住旧全局数据的所有权边界;互斥协调器让激活与写入成为原子序列;编辑器会话把多次隐式写入收敛为一次显式保存;WebChat 则按聊天元数据而非活动 prompt 解析主题。感兴趣的读者可以继续深入 issue_782_theme_editor_drafts 的完整设计,以及 ActivePromptManager.kt 与 UserPreferencesManager.kt 中的实际实现。