Operit 主题运行时快照改造:基于角色卡与群组作用域的 Android Compose 主题渲染架构

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

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,从"之前行为 → 变更 → 预期结果"三个层面展开。

变更清单

  1. 在 ActivePromptManager 中新增"活动提示快照流"(active-prompt snapshot flow);
  2. 通过 observeThemePreferenceSnapshot 读取目标前缀(target prefix);
  3. 移除角色与群组的投影切换 API(projection-switch APIs);
  4. 从 UserPreferencesManager 移除过时的全局主题 Flow 访问器;
  5. 保持主题变更、草稿提交、重置、头像与聊天标题始终限定在其 prompt 目标作用域内;
  6. 要求每一个快照读取者与主题写入者都收到非空的目标前缀。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)的组合逻辑是:

  1. 订阅 activePromptFlow 与 rememberActiveThemePreferenceSnapshot();
  2. 从 themeSnapshot 解构出 useSystemTheme、themeMode、useCustomColors、customPrimaryColor、useBackgroundImage、backgroundImageUri、backgroundMediaType、useCustomFont、fontType、fontScale 等视觉参数;
  3. 依据参数构建 ColorScheme(Android 12+ 动态取色或自定义主色生成)、自定义 Typography;
  4. 通过 SideEffect 应用状态栏/导航栏颜色与沉浸式配置;
  5. 在 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) }

对应源码:Theme.kt 与 Theme.kt。

消费者清单(源码可证)

从仓库搜索 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),这仅服务于遗留迁移场景。它对外暴露的两种合法调用方式:

  1. 作用域克隆:cloneThemeBetweenCharacterCards(sourceId, targetId) / cloneThemeBetweenCharacterGroups(sourceId, targetId)(UserPreferencesManager.kt),把某个角色卡/群组的作用域前缀克隆到另一个角色卡/群组前缀——克隆的是作用域到作用域,绝不可能写入全局键;
  2. 遗留迁移: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,改造完成后执行的静态检查包括:

  1. 搜索已移除的投影切换 API 与投影写入参数;
  2. 搜索 UserPreferencesManager 上对运行时主题字段的直接读取;
  3. 确认主题复制调用全部使用作用域目标前缀;
  4. 运行 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 与配套测试保证迁移仅在合法条件下发生一次。

延伸阅读

登录后查看全文
Operit