Operit 主题运行时快照改造:基于角色卡与群组作用域的 Android Compose 主题渲染架构
Operit 主题运行时快照改造:基于角色卡与群组作用域的 Android Compose 主题渲染架构
导读
Operit(Android 端 AI Agent 与 AI 聊天软件)在 Issue 782 系列改造中引入了"角色卡/群组作用域主题快照"机制:每一张角色卡、每一个角色群组都可以拥有自己独立的主题偏好(深浅色模式、自定义主色、背景图、聊天气泡样式、字体等)。本文基于仓库内 issue_782_theme_runtime_snapshot/index.md 及其三个子文档(1_ScopedSnapshotDataFlow.md、2_ComposeRuntimeConsumers.md、3_StaticVerification.md),系统讲解"主题运行时快照"这一关键改造:如何让 Android Compose 直接消费活动提示(active prompt)作用域下的 ThemePreferenceSnapshot,彻底移除全局主题投影层。读完本文,你将掌握 Operit 主题数据流的设计、快照数据结构、Compose 运行时消费者接入方式,以及作用域复制与旧数据迁移的边界约束。
背景:作用域主题已持久化,运行时却仍读全局键
改造前的双重表示问题
在运行时快照改造之前,Operit 的现状是"存储层已按作用域持久化、渲染层仍按全局读取":
UserPreferencesManager会把当前激活的角色卡(character card)或角色群组(character group)的主题前缀拷贝到全局键(global keys);OperitTheme与聊天界面等 Compose 消费者观察的正是这些全局键;- 因此,激活某张角色卡或某个群组时,需要先把它的快照"投影"到全局键上,UI 才能渲染出来。
也就是说,全局键成为"活动目标主题的第二个可变表示"(a second mutable representation of the active target theme)。两份数据并存,存在同步漂移、写入竞态与语义混乱的风险,这也是本次改造要解决的问题。
改造意图
依据 index.md 的 Intent,本次改造的核心是:
让 Android 运行时渲染直接使用活动提示(active prompt)作用域下的
ThemePreferenceSnapshot;保留现有的作用域键格式与"迁移进默认角色作用域"的旧逻辑,但移除所有运行时投影写入与读取。
预期结果
- 切换活动提示后,Compose 直接从该目标的作用域快照更新;
- 主题编辑只写入所选目标的作用域值;
- 通过主题复制 API 无法再把作用域值拷贝进全局键;
- 遗留的全局值只被"一次性迁移进默认角色作用域"的逻辑读取。
作用域快照的数据流设计
本节对应 1_ScopedSnapshotDataFlow.md,从"之前行为 → 变更 → 预期结果"三个层面展开。
变更清单
- 在
ActivePromptManager中新增"活动提示快照流"(active-prompt snapshot flow); - 通过
observeThemePreferenceSnapshot读取目标前缀(target prefix); - 移除角色与群组的投影切换 API(projection-switch APIs);
- 从
UserPreferencesManager移除过时的全局主题 Flow 访问器; - 保持主题变更、草稿提交、重置、头像与聊天标题始终限定在其 prompt 目标作用域内;
- 要求每一个快照读取者与主题写入者都收到非空的目标前缀。
copyThemeValues只能把某个作用域前缀克隆到另一个作用域前缀,或者把遗留全局值迁移进默认角色前缀,绝不能写入全局键。
预期结果
每个角色卡或群组主题只有一份持久化的事实来源(single source of truth)。活动提示负责"选择" Compose 观察哪一份来源,而不再"复制"一份活动主题。
从源码看,这一设计在 UserPreferencesManager.kt 中体现为作用域前缀的强制约束。前缀格式定义如下:
private fun getCharacterCardThemePrefix(characterCardId: String): String =
"character_card_theme_${characterCardId}_"
private fun getCharacterGroupThemePrefix(characterGroupId: String): String =
"character_group_theme_${characterGroupId}_"
对应源码位于 UserPreferencesManager.kt。所有主题键都以 character_card_theme_<id>_ 或 character_group_theme_<id>_ 为前缀,存放在 DataStore 的 Preferences 中;全局键(无前缀)不再是运行时的合法读写目标。
observeThemePreferenceSnapshot:快照读取的唯一入口
UserPreferencesManager 提供 observeThemePreferenceSnapshot,它强制要求传入角色卡或群组目标,二者皆为空时直接抛出错误:
fun observeThemePreferenceSnapshot(
characterCardId: String? = null,
characterGroupId: String? = null
): Flow<ThemePreferenceSnapshot> {
val normalizedGroupId = characterGroupId?.trim()?.takeIf { it.isNotBlank() }
val normalizedCardId = characterCardId?.trim()?.takeIf { it.isNotBlank() }
val (source, sourceId, prefix) = when {
normalizedGroupId != null -> Triple(
"character_group", normalizedGroupId,
getCharacterGroupThemePrefix(normalizedGroupId),
)
normalizedCardId != null -> Triple(
"character_card", normalizedCardId,
getCharacterCardThemePrefix(normalizedCardId),
)
else -> error("ThemePreferenceSnapshot requires a character card or group target.")
}
return context.userPreferencesDataStore.data
.map { preferences ->
ThemePreferenceSnapshot(
source = source,
sourceId = sourceId,
values = readThemePreferenceValues(preferences, prefix),
)
}
.distinctUntilChanged()
}
对应源码:UserPreferencesManager.kt。它把 DataStore 的 Preferences 数据映射为不可变的 ThemePreferenceSnapshot,并以 distinctUntilChanged() 去重,避免无意义的重组。同文件还提供一次性取值的 resolveThemePreferenceSnapshot(UserPreferencesManager.kt)。
快照数据结构:ThemePreferenceValues 与 ThemePreferenceSnapshot
快照由两层数据结构承载,定义在 ThemePreferenceSnapshot.kt:
ThemePreferenceValues:按类型分桶的不可变值集合
data class ThemePreferenceValues(
val strings: Map<String, String> = emptyMap(),
val booleans: Map<String, Boolean> = emptyMap(),
val ints: Map<String, Int> = emptyMap(),
val floats: Map<String, Float> = emptyMap(),
)
它按 DataStore 的键类型(String / Boolean / Int / Float)分桶存储主题键值,并提供 string(name)、boolean(name)、int(name)、float(name) 以及对应的 requiredString / requiredBoolean / requiredFloat 访问器;required 系列在键缺失时直接抛出 IllegalArgumentException,从而把"快照不完整"的问题在读取时立即暴露。同时提供 withString、withBoolean、withInt、withFloat 的不可变更新方法,用于主题编辑路径构建新值集。
defaultVisual:默认主题值
ThemePreferenceValues.defaultVisual() 提供一套完整的默认视觉值(ThemePreferenceSnapshot.kt),其中关键的默认项包括:
| 类型 | 键 | 默认值 | 说明 |
|---|---|---|---|
| String | theme_mode |
light |
深浅色模式 |
| String | chat_style |
cursor |
聊天风格 |
| String | avatar_shape |
circle |
头像形状 |
| String | input_style |
agent |
输入框风格 |
| String | font_type |
system |
字体类型 |
| Boolean | use_system_theme |
true |
跟随系统主题 |
| Boolean | use_custom_colors |
false |
是否使用自定义主色 |
| Boolean | use_background_image |
false |
是否使用背景图 |
| Boolean | show_thinking_process |
true |
显示思考过程 |
| Boolean | show_status_tags |
true |
显示状态标签 |
| Float | background_image_opacity |
0.3f |
背景图透明度 |
| Float | background_blur_radius |
10f |
背景模糊半径 |
| Float | avatar_corner_radius |
8f |
头像圆角 |
| Float | font_scale |
1f |
字体缩放 |
这套默认值同时充当两个角色:一是新角色/群组初始化主题的基线;二是 Compose 组合初期、快照流尚未发射首个值时的占位快照(见下文 rememberActiveThemePreferenceSnapshot 的 initial 值)。
ThemePreferenceSnapshot:带来源信息的运行时视图
data class ThemePreferenceSnapshot(
val source: String, // "character_card" 或 "character_group"
val sourceId: String? = null, // 角色卡 id 或群组 id
val values: ThemePreferenceValues,
)
ThemePreferenceSnapshot 在 values 之上又暴露了上百个类型安全、带语义的属性访问器(themeMode、useSystemTheme、customPrimaryColor、backgroundImageUri、chatStyle、bubbleImageRenderMode、showMessageTokenStats、customChatTitle 等,见 ThemePreferenceSnapshot.kt),运行时消费者无需关心底层键名,直接以属性方式读取视觉参数。source 与 sourceId 则记录快照来自哪个角色卡或群组,便于调试与追踪。
ActivePromptManager:活动提示如何"选择"快照来源
ActivePromptManager.kt 是本改造的核心枢纽,它把"当前激活的是哪个角色卡/群组"翻译为"Compose 应该观察哪一份主题快照"。
活动提示流(activePromptFlow)
val activePromptFlow: Flow<ActivePrompt> =
combine(
characterGroupCardManager.observeActiveCharacterGroupId(),
characterCardManager.observeActiveCharacterCardId()
) { groupId, cardId ->
when {
!groupId.isNullOrBlank() -> ActivePrompt.CharacterGroup(groupId)
!cardId.isNullOrBlank() -> ActivePrompt.CharacterCard(cardId)
else -> ActivePrompt.CharacterCard(CharacterCardManager.DEFAULT_CHARACTER_CARD_ID)
}
}.distinctUntilChanged()
对应源码:ActivePromptManager.kt。群组优先级高于角色卡;两者都为空时回落到 DEFAULT_CHARACTER_CARD_ID(默认角色)。
活动主题快照流(activeThemePreferenceSnapshotFlow)
val activeThemePreferenceSnapshotFlow: Flow<ThemePreferenceSnapshot> =
activePromptFlow
.flatMapLatest { prompt ->
when (prompt) {
is ActivePrompt.CharacterGroup ->
userPreferencesManager.observeThemePreferenceSnapshot(
characterGroupId = prompt.id,
)
is ActivePrompt.CharacterCard ->
userPreferencesManager.observeThemePreferenceSnapshot(
characterCardId = prompt.id,
)
}
}
.distinctUntilChanged()
对应源码:ActivePromptManager.kt。flatMapLatest 保证:活动提示一旦切换,立刻切换到该目标的作用域快照流,旧目标的数据流被取消——这正是"切换活动提示后 Compose 直接从目标作用域快照更新"的机制来源。注意这里没有做任何"拷贝到全局键"的操作:活动提示只负责选择观察目标。
作用域内主题写入
所有主题写入 API 都要求显式传入 target: ActivePrompt,并经由 ThemeTargetOperationCoordinator 的 runTransition 串行化,避免并发写冲突:
mutateActiveThemeForPrompt(target, transform):对活动目标做增量变换(ActivePromptManager.kt),且仅当getActivePrompt() == target时执行,防止对非活动目标误写;commitThemeDraft(target, values):提交草稿(替换整份值);resetThemeDraft(target, values):重置主题;saveAiAvatarForPrompt(target, avatarUri):保存 AI 头像;saveCustomChatTitleForPrompt(target, title):保存自定义聊天标题。
这些方法最终都落到 UserPreferencesManager 的 mutateThemeForPrompt、replaceThemeForPrompt、resetVisualThemeForPrompt、saveAiAvatarForCharacterCard/Group、saveCustomChatTitleForCharacterCard/Group 等按前缀写入的实现上,保证"主题编辑只写入选定目标的作用域值"。
聊天绑定激活
activateForChatBinding(characterCardName, characterGroupId)(ActivePromptManager.kt)负责从聊天绑定信息解析并激活提示:优先群组 id,其次按角色卡名查找角色卡,找不到则回落到默认角色卡。
Compose 运行时消费者:从全局 Flow 到局部快照
本节对应 2_ComposeRuntimeConsumers.md。
改造前的读取面
改造前,主题渲染、导航外观、设置页背景、聊天背景、消息气泡、光标消息展示、悬浮全屏消息以及独立的图片生成 Compose 视图,都直接读取 UserPreferencesManager 的全局主题 Flow——即所有渲染面共享同一份"全局投影",无法体现角色差异。
改造后:CompositionLocal 下发活动快照
改造后,OperitTheme 在组合根部通过 LocalThemePreferenceSnapshot 下发当前活动快照,所有消费者从该局部快照读取视觉值。
先看快照的提供端。ThemePreferenceLocals.kt(ThemePreferenceLocals.kt)定义了两个关键元素:
val LocalThemePreferenceSnapshot =
compositionLocalOf<ThemePreferenceSnapshot> {
error("LocalThemePreferenceSnapshot is not provided.")
}
@Composable
fun rememberActiveThemePreferenceSnapshot(): ThemePreferenceSnapshot {
val context = LocalContext.current
val activePromptManager = remember(context) { ActivePromptManager.getInstance(context) }
val themeSnapshot by activePromptManager.activeThemePreferenceSnapshotFlow.collectAsState(
initial = ThemePreferenceSnapshot(
source = "character_card",
sourceId = CharacterCardManager.DEFAULT_CHARACTER_CARD_ID,
values = ThemePreferenceValues.defaultVisual(),
),
)
return themeSnapshot
}
注意 compositionLocalOf 的默认值直接 error(...):任何没有经过 OperitTheme 提供该 Local 的独立组合根,读取时会立刻崩溃,从而强制所有消费者显式接入快照体系,杜绝"偷偷读全局键"的回退路径。rememberActiveThemePreferenceSnapshot 则用 collectAsState 订阅活动快照流,并以默认角色 + 默认视觉值作为初始占位。
OperitTheme(Theme.kt)的组合逻辑是:
- 订阅
activePromptFlow与rememberActiveThemePreferenceSnapshot(); - 从
themeSnapshot解构出useSystemTheme、themeMode、useCustomColors、customPrimaryColor、useBackgroundImage、backgroundImageUri、backgroundMediaType、useCustomFont、fontType、fontScale等视觉参数; - 依据参数构建
ColorScheme(Android 12+ 动态取色或自定义主色生成)、自定义Typography; - 通过
SideEffect应用状态栏/导航栏颜色与沉浸式配置; - 在
CompositionLocalProvider(LocalThemePreferenceSnapshot provides themeSnapshot, ...)中包裹MaterialTheme与内容。
关键代码片段:
val themeSnapshot = rememberActiveThemePreferenceSnapshot()
val useSystemTheme = themeSnapshot.useSystemTheme
val themeMode = themeSnapshot.themeMode
val useCustomColors = themeSnapshot.useCustomColors
val customPrimaryColor = themeSnapshot.customPrimaryColor
...
CompositionLocalProvider(
LocalThemePreferenceSnapshot provides themeSnapshot,
...
) { ... MaterialTheme(colorScheme = colorScheme, typography = customTypography, content = content) }
消费者清单(源码可证)
从仓库搜索 LocalThemePreferenceSnapshot 的引用可以看到,运行时消费者已全面切换到局部快照:
| 消费者 | 文件 |
|---|---|
| 应用根主题 | Theme.kt |
| 导航抽屉外观 | NavigationDrawerAppearance.kt |
| 应用内容容器 | AppContent.kt |
| 聊天区域 | ChatArea.kt |
| AI/用户气泡 | BubbleAiMessageComposable.kt、BubbleUserMessageComposable.kt |
| 光标消息展示 | AiMessageComposable.kt |
| AI 聊天主屏 | AIChatScreen.kt |
| 设置页 | SettingsScreen.kt、GlobalDisplaySettingsScreen.kt、ContextSummarySettingsScreen.kt、ThemeSettingsContentEditor.kt |
| 悬浮聊天窗 | FloatingChatWindow.kt |
| 悬浮全屏消息 | MessageDisplay.kt、FloatingFullscreenScreen.kt |
| 图片生成视图 | MessageImageGenerator.kt |
其中"消息统计显示标志(message-stat display flags)"与"设置页背景处理(settings-surface background treatment)"也在改造中明确要求从局部快照读取(见 2_ComposeRuntimeConsumers.md),如 showMessageTokenStats、showMessageTimingStats、showMessageTokenSpeed 等布尔属性均由 ThemePreferenceSnapshot 提供。
独立组合根的接入:悬浮窗与图片生成视图
普通的组合面只要位于 OperitTheme 之下即可自动获得 LocalThemePreferenceSnapshot;但悬浮聊天窗与图片生成 ComposeView 是独立的组合根(composition root),需要自行解析并下发活动快照:
- FloatingChatWindow.kt:调用
rememberActiveThemePreferenceSnapshot()获取当前活动快照,再CompositionLocalProvider(LocalThemePreferenceSnapshot provides themeSnapshot)包裹自己的内容树; - MessageImageGenerator.kt:同样在独立的
ComposeView中提供LocalThemePreferenceSnapshot,使图片生成界面也遵循当前活动角色/群组的主题(如气泡渲染模式bubble_image_render_mode)。
预期结果
切换活动提示会让所有 Android 主题消费者从同一份作用域快照更新;没有任何渲染面再依赖全局主题投影。
复制与迁移边界:全局键成为只读禁区
copyThemeValues 的两种合法用途
copyThemeValues(UserPreferencesManager.kt)是底层复制原语,其签名强制要求 targetPrefix 非空:
private fun copyThemeValues(
preferences: MutablePreferences,
sourcePrefix: String?, // null 表示读取无前缀的遗留全局键
targetPrefix: String, // 目标必须是作用域前缀
clearMissingTargetValues: Boolean,
)
它在四个键类型(String / Boolean / Int / Float)上逐键复制;sourcePrefix == null 时读取的正是无前缀的全局键(preferences[sourceKey],其中 sourceKey = key),这仅服务于遗留迁移场景。它对外暴露的两种合法调用方式:
- 作用域克隆:
cloneThemeBetweenCharacterCards(sourceId, targetId)/cloneThemeBetweenCharacterGroups(sourceId, targetId)(UserPreferencesManager.kt),把某个角色卡/群组的作用域前缀克隆到另一个角色卡/群组前缀——克隆的是作用域到作用域,绝不可能写入全局键; - 遗留迁移:
migrateLegacyDefaultCharacterThemeIfEligible(...)(UserPreferencesManager.kt),把无前缀的遗留全局值迁入默认角色前缀,这是全局键唯一合法的读取路径。
迁移策略与测试验证
迁移是否执行由 ThemeScopeMigrationPolicy.shouldCopyLegacyThemeToDefaultCharacter(...) 决策,入参包括:迁移是否已完成、当前活动角色卡 id、默认角色 id、默认角色是否已有主题、是否存在任何作用域主题、默认角色是否刚被创建。对应测试 ThemeScopeMigrationPolicyTest.kt 覆盖了 5 个关键分支:
- 活动角色为默认角色、且无任何作用域数据时 → 迁移(
activeDefaultWithoutScopedDataMigratesLegacyTheme); - 已存在其他作用域主题数据时 → 不迁移(
existingScopedDataPreventsLegacyThemeMigration); - 默认角色刚被创建 → 迁移(
newlyCreatedDefaultCardMigratesExistingGlobalTheme); - 活动角色未知(id 为 null)→ 不迁移(
unknownActiveCardDoesNotAssignLegacyThemeToDefault); - 迁移已完成 → 不再重复执行(
completedMigrationDoesNotRunAgain)。
迁移完成后会写入 CHARACTER_THEME_DEFAULT_MIGRATION_COMPLETED 标记,保证一次性语义。此外,hasAnyScopedThemeContent 的判定把 custom_ai_avatar_uri 与 custom_chat_title 排除在外(UserPreferencesManager.kt),即头像与标题的存在不会误触发主题迁移。
静态验证:确认投影入口已清零
对应 3_StaticVerification.md,改造完成后执行的静态检查包括:
- 搜索已移除的投影切换 API 与投影写入参数;
- 搜索
UserPreferencesManager上对运行时主题字段的直接读取; - 确认主题复制调用全部使用作用域目标前缀;
- 运行
git diff --check检查补丁卫生。
检查结论:未发现任何残留的活动主题投影入口点(active-theme projection entry point)或直接的全局主题运行时读取(direct global-theme runtime read)。从当前仓库源码复核,这一结论依然成立:observeThemePreferenceSnapshot 在无目标时直接 error(...)(UserPreferencesManager.kt),LocalThemePreferenceSnapshot 在未提供时直接 error(...)(ThemePreferenceLocals.kt),copyThemeValues 的调用点全部落在作用域前缀上——双重强约束从 API 层面杜绝了投影回退。仓库文档同时注明:构建与测试命令当时因需显式批准而有意未运行,读者在本地验证时可按需自行执行。
架构要点小结
- 单一份事实来源:每个角色卡/群组的主题只存于自己的作用域前缀(
character_card_theme_<id>_/character_group_theme_<id>_),全局键不再承载运行时主题; - 选择而非复制:
ActivePromptManager用flatMapLatest把活动提示映射为对应作用域的observeThemePreferenceSnapshot流,切换提示即切换观察源; - 局部下发:
OperitTheme通过LocalThemePreferenceSnapshot下发活动快照,所有 Compose 消费者从局部快照取视觉值;悬浮窗与图片生成等独立组合根自行解析并下发; - 写入限定作用域:主题变更、草稿提交、重置、头像、聊天标题均需显式
target: ActivePrompt,并经过ThemeTargetOperationCoordinator串行化; - 复制与迁移有界:
copyThemeValues只做作用域→作用域克隆或全局→默认角色的一次性迁移;ThemeScopeMigrationPolicy与配套测试保证迁移仅在合法条件下发生一次。
延伸阅读
- 主题持久化与投影机制的整体设计:issue_782_theme_persistence/index.md
- 主题作用域契约与编辑器目标草稿:issue_782_theme_editor_drafts/index.md
- 主题作用域改造的早期设计与迁移:issue_782_theme_editor_drafts/1_ThemeScopeContract.md
- 快照数据结构定义:ThemePreferenceSnapshot.kt
- 活动提示与快照流:ActivePromptManager.kt
- 主题读写与迁移实现:UserPreferencesManager.kt
- 运行时提供与消费:Theme.kt、ThemePreferenceLocals.kt
- 迁移策略单元测试:ThemeScopeMigrationPolicyTest.kt