【时光清单|07】HarmonyOS ArkTS 主题系统实战:集中管理亮暗色和语义颜色
HarmonyOS 应用的主题系统不是“准备几组背景色”这么简单。用户选择一种视觉主题后,页面背景、卡片、主色、正文、次要文字和系统栏必须一起变化;系统从亮色切到暗色时,当前主题仍要保留,只切换到对应的暗色令牌。如果每个页面自己判断isDark、自己拼十六进制颜色,最终一定会出现遗漏:主体已经变暗,弹窗仍是白色;页面背景已切换,状态栏图标却看不清;某个主题有暗色主色,另一个页面仍使用亮色常量。
时光清单的真实源码把主题模型定义在Theme.ets,由AppStore.applyTheme()根据主题 ID 和当前颜色模式解析语义色,再写入AppStorage。页面使用@StorageLink(StateKeys.THEME_BG)等键响应变化;EntryAbility.onConfigurationUpdate()监听系统颜色模式,并重新应用当前主题。项目里还存在ThemeManager与ThemeService两个并行抽象,但主要页面实际调用的是AppStore,这一点必须先辨清。
本文沿真实调用链拆解主题定义、亮暗色解析、系统配置监听、状态栏适配、时间氛围背景与主题选择页面,同时指出当前语义色覆盖不完整、主题持久化尚未真正接通以及多套主题入口并存的工程风险。
本文将完成这些源码复核:
- 说明
AppTheme为什么同时保存亮色与暗色令牌。 - 还原主题选择到所有页面刷新的真实链路。
- 分析系统深色模式变化如何重新解析当前主题。
- 解释状态栏内容色为什么要跟随背景亮度。
- 区分当前生效的
AppStore与并存的ThemeManager、ThemeService。 - 给出语义色、持久化、对比度和多设备测试的渐进改造方案。
本文唯一标记:
CSDN-SERIES:ALL-163203514
证据边界:当前源码、历史记录与建议实现
本文的当前事实来自主题模型、AppStore、状态键、主题页面、入口 Ability、亮暗资源文件以及定向引用搜索。静态源码可以证明字段、调用关系和未接通的分支,但不能单独证明所有页面在真机上都可读,也不能证明每套颜色已通过对比度检测。本轮没有运行构建、设备或截图回归,所以不会补写这些结果。
历史部分只引用项目PROJECT_ERRORS.md的明确记录。该记录写明 2026 年 5 月 20 日曾出现“深夜星空主题导致全部 Tab 页字体看不清”,并记录了根因、修复和当时的assembleHap成功。那是历史证据,不等于本轮重新构建通过。下文带“建议”“应当”的方案同样不代表已经实现。
一、主题系统首先解决“语义”,不是色值
页面通常不应该关心当前主题的背景到底是#F5F0E8还是#121212。页面只需要表达“这里是页面背景”“这里是卡片表面”“这里是主要文字”。具体色值由主题解析层决定。
时光清单的AppTheme定义了主题身份和语义角色:
export type ThemeId = 'chinese_ink' | 'sunset' | 'minimal_white' | 'starry_night' | 'sakura'; export interface AppTheme { id: ThemeId; name: string; primaryColor: string; secondaryColor: string; backgroundColor: string; cardColor: string; textPrimary: string; textSecondary: string; backgroundImage?: string; darkPrimaryColor: string; darkBackgroundColor: string; darkCardColor: string; }这些字段不是随意的一组颜色,而是设计令牌:
| 令牌 | 页面语义 | 亮色示例 | 暗色示例 |
|---|---|---|---|
primaryColor | 品牌、选中、强调 | 国风蓝 | 提亮后的蓝 |
backgroundColor | 页面底色 | 米白 | 深蓝黑 |
cardColor | 卡片和表面 | 白色 | 深灰蓝 |
textPrimary | 标题、正文 | 深色 | 当前由解析层统一浅色 |
textSecondary | 辅助信息 | 中灰 | 当前由解析层统一浅灰 |
页面消费“角色”而不是“主题名字”,才能让五套主题和两种颜色模式组合成十种状态,而不把条件分支复制到每个组件。
二、五套真实主题如何组织
源码中的THEMES包含国风山水、治愈日落、极简白、深夜星空和樱花树:
export const THEMES: AppTheme[] = [ { id: 'chinese_ink', name: '国风山水', primaryColor: '#2C5F7C', backgroundColor: '#F5F0E8', cardColor: '#FFFFFF', textPrimary: '#1A1A1A', textSecondary: '#666666', darkPrimaryColor: '#6C8EBF', darkBackgroundColor: '#1A1A2E', darkCardColor: '#2D2D44' }, { id: 'minimal_white', name: '极简白', primaryColor: '#333333', backgroundColor: '#FFFFFF', cardColor: '#F8F8F8', darkPrimaryColor: '#E0E0E0', darkBackgroundColor: '#121212', darkCardColor: '#1E1E1E' } ];每套主题都必须提供同一组必填字段。这样getThemeById()不需要知道主题细节:
export function getThemeById(id: ThemeId): AppTheme { return THEMES.find( (t: AppTheme) => t.id === id ) ?? THEMES[0]; }未知 ID 会降级到第一套主题,避免配置迁移或非法输入导致页面没有颜色。不过降级只是运行时保护,持久化恢复时仍应验证旧值,必要时记录迁移,而不是长期静默掩盖错误。
三、当前真正生效的入口是 AppStore
项目虽然有ThemeManager和ThemeService,但从页面引用关系看,主题设置页直接调用:
.onClick(() => { this.selectedTheme = theme.id; AppStore.applyTheme(theme.id); })AppStore.applyTheme()是当前主要解析器:
static applyTheme(themeId: ThemeId): void { const theme = getThemeById(themeId); const isDark = AppStorage.get<boolean>(StateKeys.DARK_MODE) ?? false; const bg = isDark ? theme.darkBackgroundColor : theme.backgroundColor; const card = isDark ? theme.darkCardColor : theme.cardColor; const primary = isDark ? theme.darkPrimaryColor : theme.primaryColor; const textPrimary = isDark ? '#E8E8F0' : theme.textPrimary; const textSecondary = isDark ? '#D0D0E4' : theme.textSecondary; AppStore.setStorageString( StateKeys.CURRENT_THEME, themeId ); AppStore.setStorageString(StateKeys.THEME_BG, bg); AppStore.setStorageString(StateKeys.THEME_CARD, card); AppStore.setStorageString( StateKeys.THEME_PRIMARY, primary ); AppStore.setStorageString( StateKeys.THEME_TEXT_PRIMARY, textPrimary ); AppStore.setStorageString( StateKeys.THEME_TEXT_SECONDARY, textSecondary ); AppStore.updateStatusBarStyle(bg); }输入只有两个:themeId和isDark。输出是一组已经解析好的语义颜色。页面不再执行亮暗色分支,这是集中主题系统最核心的价值。
四、用 AppStorage 把解析结果广播到页面
StateKeys给主题值定义统一键名:
static readonly DARK_MODE: string = 'isDarkMode'; static readonly CURRENT_THEME: string = 'currentThemeId'; static readonly THEME_BG: string = 'themeBgColor'; static readonly THEME_CARD: string = 'themeCardColor'; static readonly THEME_PRIMARY: string = 'themePrimaryColor'; static readonly THEME_TEXT_PRIMARY: string = 'themeTextPrimary'; static readonly THEME_TEXT_SECONDARY: string = 'themeTextSecondary';首页、全部列表、详情页、日记、相册、习惯、心愿等页面都订阅THEME_BG:
@StorageLink(StateKeys.THEME_BG) themeBg: string = '#F5F0E8'; build() { Column() { // 页面内容 } .width('100%') .height('100%') .backgroundColor(this.themeBg); }主题设置页还订阅当前主题:
@StorageLink(StateKeys.CURRENT_THEME) currentThemeId: string = 'chinese_ink'; @State selectedTheme: ThemeId = 'chinese_ink'; aboutToAppear(): void { this.selectedTheme = this.currentThemeId as ThemeId; }用户点击主题后,AppStore更新多个 AppStorage 键,所有仍在组件树中的页面都能响应。平板或 2in1 双栏布局可能同时显示导航与内容页面,这种广播比“返回页面时再读取设置”更可靠。
五、启动链路:默认主题、窗口与系统栏
EntryAbility.onCreate()调用AppStore.bootstrap():
AppStore.bootstrap(this.context); DataStore.getInstance().init(this.context); this.hydratePersistentState();bootstrap()初始化颜色模式、当前主题和各类运行时状态,然后应用默认国风主题:
AppStorage.setOrCreate<boolean>( StateKeys.DARK_MODE, isDark ); AppStorage.setOrCreate<string>( StateKeys.CURRENT_THEME, 'chinese_ink' ); AppStore.applyTheme('chinese_ink');窗口创建后,应用保存主窗口引用,并再次使用当前主题更新系统栏:
const win = windowStage.getMainWindowSync(); AppStore.setMainWindow(win); const themeId = ( AppStorage.get<string>( StateKeys.CURRENT_THEME ) ?? 'chinese_ink' ) as ThemeId; AppStore.applyTheme(themeId);第二次应用不是简单重复。bootstrap()执行时主窗口可能尚未建立,无法设置状态栏内容色;窗口创建后重新应用,才能让系统栏与页面背景保持一致。
六、系统颜色模式变化如何进入主题系统
EntryAbility.onConfigurationUpdate()监听系统配置:
onConfigurationUpdate( newConfig: Configuration ): void { const isDark = newConfig.colorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK; const currentDark = AppStorage.get<boolean>( StateKeys.DARK_MODE ) ?? false; if (isDark !== currentDark) { AppStore.onDarkModeChanged(isDark); } }onDarkModeChanged()不改变主题 ID,而是更新模式后重新应用当前主题:
static onDarkModeChanged(isDark: boolean): void { AppStorage.set<boolean>( StateKeys.DARK_MODE, isDark ); const themeId = ( AppStorage.get<string>( StateKeys.CURRENT_THEME ) ?? 'chinese_ink' ) as ThemeId; AppStore.applyTheme(themeId); }因此,用户选择“樱花树”后切换系统暗色,仍然是樱花主题,只是背景、卡片和主色切换到该主题的暗色令牌。主题身份和颜色模式是两个独立维度:
ThemeId: sakura ColorMode: light -> sakura.backgroundColor -> sakura.cardColor -> sakura.primaryColor ThemeId: sakura ColorMode: dark -> sakura.darkBackgroundColor -> sakura.darkCardColor -> sakura.darkPrimaryColor七、应用内手动切换亮暗色
个人页提供亮色和暗色模式按钮,最终调用:
static applyColorMode(dark: boolean): void { if (AppStore.appContext === null) return; try { const mode: ConfigurationConstant.ColorMode = dark ? ConfigurationConstant.ColorMode .COLOR_MODE_DARK : ConfigurationConstant.ColorMode .COLOR_MODE_LIGHT; AppStore.appContext.setColorMode(mode); AppStore.onDarkModeChanged(dark); } catch (_e) {} }这里既调用应用级setColorMode(),又立即更新本地状态。用户不必等待配置回调才看到变化。
当前界面实际显示“跟随系统”“浅色”“深色”三个标签,但ModeChip点击处理只实现了dark和light两个分支,auto没有执行动作。bootstrap()会调用COLOR_MODE_NOT_SET让默认启动行为跟随系统;用户一旦手动固定模式,现有“跟随系统”标签并不能把应用重置为系统模式。这里需要补齐第三种偏好和值到平台模式的映射,而不是继续用一个布尔变量表达三态。
更完整的模型应是:
export type ColorModePreference = 'system' | 'light' | 'dark';DARK_MODE表示当前实际模式,COLOR_MODE_PREFERENCE表示用户偏好,两者不能混为一谈。
八、系统栏内容色:背景变化后的最后一步
沉浸式布局把页面背景延伸到状态栏区域。如果背景变暗而状态栏仍显示黑色图标,时间、电量和网络状态会失去可读性。
源码通过背景亮度估算内容色:
private static isLightColor(hex: string): boolean { const c = hex.replace('#', ''); const r = parseInt(c.substring(0, 2), 16); const g = parseInt(c.substring(2, 4), 16); const b = parseInt(c.substring(4, 6), 16); return ( r * 0.299 + g * 0.587 + b * 0.114 ) > 128; }然后同步状态栏与导航栏图标颜色:
const contentColor = isLight ? '#000000' : '#FFFFFF'; AppStore.mainWindow .setWindowSystemBarProperties({ statusBarContentColor: contentColor, navigationBarContentColor: contentColor });这是一种实用的二值判断,但只接受六位十六进制颜色。若未来加入八位透明色、资源引用、渐变或背景图片,解析函数就不能准确代表实际背景。更稳妥的做法是在主题模型里直接声明系统栏内容风格:
interface SystemBarTokens { lightContent: boolean; navigationBarColor: string; }设计令牌比运行时猜测更可控,尤其是在复杂背景和图片主题中。
九、语义色覆盖还不完整
AppStore已经广播背景、卡片、主色、主要文字和次要文字,但主题设置页仍大量使用静态AppColors:
Text(theme.name) .fontColor(AppColors.textPrimary) Text(this.getThemeDesc(theme.id)) .fontColor(AppColors.textTertiary) .backgroundColor(AppColors.bgSurface)这说明当前主题系统处于“动态页面背景已经接通,组件内部语义色仍部分静态”的阶段。换主题时背景会变化,但卡片文字、表面色和部分强调色未必完全跟随。
修复方向不是在页面里增加更多isDark ? ... : ...,而是让页面订阅完整令牌:
@StorageLink(StateKeys.THEME_CARD) themeCard: string = '#FFFFFF'; @StorageLink(StateKeys.THEME_PRIMARY) themePrimary: string = '#2C5F7C'; @StorageLink(StateKeys.THEME_TEXT_PRIMARY) themeTextPrimary: string = '#1A1A1A'; @StorageLink(StateKeys.THEME_TEXT_SECONDARY) themeTextSecondary: string = '#666666';随后将可变主题色从静态AppColors替换为对应语义状态。固定的错误色、警告色和成功色也应单独设计,不应直接复用品牌主色。
十、ThemeManager 与 ThemeService 的真实边界
ThemeManager是@Observed单例:
@Observed export class ThemeManager { private static instance: ThemeManager | null = null; currentTheme: AppTheme = THEMES[0]; switchTheme(id: ThemeId): void { this.currentTheme = getThemeById(id); AppStorage.setOrCreate( 'currentThemeId', id ); } }ThemeService则只在静态字段中保存当前 ID:
export class ThemeService { private static currentThemeId: ThemeId = 'chinese_ink'; static setTheme(id: ThemeId): void { ThemeService.currentThemeId = id; } static getTheme(): AppTheme { return getThemeById( ThemeService.currentThemeId ); } }两者还都提供按小时计算氛围背景的能力。然而从项目引用看,主题设置和颜色模式实际由AppStore驱动,主要页面也没有订阅ThemeManager.currentTheme或读取ThemeService.getTheme()。
因此不能把三套机制描述成一个已统一系统。更准确的结论是:
| 抽象 | 当前能力 | 主链路状态 |
|---|---|---|
AppStore | 解析亮暗色、广播语义色、更新系统栏 | 当前生效 |
ThemeManager | 观察对象、切换主题、自动背景 | 并存,主要页面未采用 |
ThemeService | 静态主题 ID、氛围背景映射 | 并存,主要页面未采用 |
工程上应该保留一个主题写入口。若确认AppStore是正式方案,就把自动背景能力迁入专门的MoodService,逐步删除或停止扩展另外两套主题状态,避免出现三个“当前主题”。
十一、时间氛围背景是第二个维度
getMoodToneByHour()将小时映射为四个时段:
export function getMoodToneByHour( hour: number ): MoodTone { if (hour >= 5 && hour < 9) return 'morning'; if (hour >= 9 && hour < 16) return 'noon'; if (hour >= 16 && hour < 19) return 'dusk'; return 'night'; }ThemeService.getMoodBackground()再映射资源名:
static getMoodBackground( tone: MoodTone ): string { switch (tone) { case 'morning': return 'bg_hero_mountain'; case 'noon': return 'bg_card_salary'; case 'dusk': return 'bg_card_couple'; case 'night': return 'bg_card_quote'; case 'rain': case 'snow': return 'bg_card_countdown'; } }主题解决颜色语言,氛围背景解决内容场景。两者可以组合,但不应该互相覆盖:
主题:决定背景底色、卡片、文字与强调色 氛围:决定可选背景图片 颜色模式:决定亮色令牌或暗色令牌背景图片必须有遮罩或文字保护策略。不能因为图片“看起来好看”,就让正文对比度随时段和素材明暗随机变化。
十二、当前主题并未真正持久化
StateKeys注释提到AppStorage / PersistentStorage,但源码搜索不到把CURRENT_THEME连接到PersistentStorage的实际调用;EntryAbility.hydratePersistentState()当前只恢复MOOD_BACKGROUND。
同时,bootstrap()每次启动都执行:
AppStore.applyTheme('chinese_ink');这意味着本次运行内切换主题有效,但从当前可见源码不能确认主题选择会跨启动保存。文章不能把“写入 AppStorage”误写为“完成持久化”。
若要跨启动保留主题,可沿项目现有DataStore模式实现:
interface AppearancePreference { themeId: ThemeId; colorMode: ColorModePreference; moodBackground: string; }启动时先同步读取偏好,再初始化 AppStorage 和应用主题;切换主题时先保存偏好,成功后广播新令牌。还要考虑旧版本没有字段、主题 ID 已移除和非法值降级。
十三、主题切换的正确时序
完整时序应保持单向:
用户选择主题 -> 校验 ThemeId -> 保存用户偏好(若支持跨启动) -> 读取当前实际颜色模式 -> 解析一组完整语义令牌 -> 原子式更新主题状态 -> 页面响应 -> 更新状态栏与导航栏当前AppStore逐个写 AppStorage 键。ArkUI 更新通常足够快,但理论上组件可能短暂观察到新背景与旧文字。若主题规模扩大,可把令牌封装为一个对象:
interface ResolvedThemeTokens { id: ThemeId; dark: boolean; background: string; surface: string; primary: string; textPrimary: string; textSecondary: string; }页面订阅一个完整快照,可以减少中间状态。不过 ArkTS 和 ArkUI 对复杂对象观察方式有明确要求,改造前应结合现有状态模型验证,不能只照搬 Web 状态管理习惯。
十四、颜色对比度是发布门槛
主题越多,组合测试越容易漏。AppGallery 审核关注关键文字、图标、按钮和正文的可读性。工程上至少采用以下目标:
- 正文与背景对比度不低于
4.5:1。 - 大号文字、图标和关键操作与背景对比度高于
3:1。 - 禁用态仍可识别,但不能与正常态混淆。
- 选中不能只依赖颜色,还要有边框、图标或文案。
当前主题选择页用三段色块预览主色、背景和卡片色,这是很好的快速识别方式;选中项还显示“✓ 使用中”并增加边框,不只依赖色差。
但暗色预览尚未展示。更完善的预览可以在每个主题卡中同时显示亮、暗两组迷你色板,或者让预览跟随当前模式,避免用户在亮色环境选择主题后进入暗色才发现效果不合适。
十五、不要忘记图标、弹窗和系统区域
主题测试不能只看页面根背景。每套主题都要覆盖:
- 状态栏和导航栏。
- 底部导航选中与未选中态。
- 卡片、列表项、分割线和阴影。
- 对话框、底部弹窗和 Toast。
- 输入框、占位符、光标与错误提示。
- 空状态、加载态、失败态和禁用态。
- 图片上的文字与半透明遮罩。
- 可染色图标和不可染色位图。
如果图标本身是深灰色 PNG,切到深色卡片后可能消失。可染色资源应使用统一 tint 令牌;必须保留原色的位图,则要准备适配版本或稳定的承载底色。
十六、多设备与窗口变化测试
手机上主题切换通常只有一个页面可见,平板和 2in1 可能同时存在侧栏、列表和详情。所有区域必须订阅同一主题令牌,不能只有新打开页面使用新主题。
建议覆盖:
手机竖屏: 五套主题 × 亮色/暗色 手机横屏/小窗口: 长标题、底部安全区、弹窗 平板: 双栏同时切换,不出现一明一暗 2in1: 调整窗口尺寸后主题与系统栏不丢失 系统动态切换: 应用前台、后台恢复、配置更新还要测试切换时页面是否闪白。首帧先显示默认主题、异步读取偏好后再切换,会产生明显闪烁。EntryAbility已经对心情背景采用同步初始化,这一思路也适用于外观偏好:在加载页面内容之前取得最小主题配置。
十七、异常与降级策略
主题系统不应因一个非法颜色让应用崩溃。建议定义清晰降级:
| 异常 | 降级 |
|---|---|
| 未知主题 ID | 回退chinese_ink |
| 非法颜色字符串 | 使用默认语义令牌 |
| 状态栏更新失败 | 记录日志,页面继续显示 |
| 背景图片缺失 | 使用纯色背景 |
| 偏好读取失败 | 默认跟随系统 |
| 主题保存失败 | 保持旧主题并提示 |
当前updateStatusBarStyle()已通过try/catch避免窗口 API 失败影响页面,符合“装饰能力失败不阻断核心流程”的原则。但空catch不应遍布主题主链路,至少需要可诊断日志。
十八、渐进式整理方案
结合现有源码,最稳妥的改造顺序是:
- 明确
AppStore为唯一主题写入口。 - 将页面中可变的
AppColors替换为动态语义令牌。 - 新增
ColorModePreference,支持系统、亮色、暗色三态。 - 使用现有
DataStore持久化主题与颜色模式偏好。 - 启动时同步恢复偏好,避免首帧闪烁。
- 将自动背景逻辑迁到独立
MoodService。 - 清理未使用的
ThemeManager、ThemeService或ThemeConfig重复职责。 - 为每套主题建立对比度与多设备截图基线。
每一步都应保持ThemeId -> Resolved Tokens -> Page这条单向链路,不让页面重新成为颜色决策者。
十九、可复核测试清单
模型层
- 五个合法 ID 都能解析。
- 非法 ID 回退第一套主题。
- 每套主题亮暗色必填字段完整。
getMoodToneByHour()覆盖 5、9、16、19 点边界。
状态层
- 切换主题后所有语义键更新。
- 深色切换保持当前主题 ID。
- 同一主题重复应用不会产生异常。
- 主窗口存在时系统栏内容色同步。
- 主窗口不存在时不阻断启动。
页面层
- 主题设置页选中标记正确。
- 所有存活页面同步刷新。
- 长文案和列表滚动不受颜色切换影响。
- 错误、空状态和禁用态可读。
- 背景图片加载失败时仍有纯色底。
发布层
- 五套主题在亮暗模式下通过对比度检查。
- 状态栏、导航栏与页面背景一致。
- 手机、平板、2in1 不出现局部旧主题。
- 重启后的外观行为与设置文案一致。
- 隐私说明不虚构联网或云端主题能力。
二十、历史问题为什么值得保留为回归样例
项目记录中的深夜星空问题不是抽象风险,而是主题系统最典型的失配:页面背景和主色已经切换,Tab 外壳却没有消费同一组文字令牌;同时,旧实现用setOrCreate写已存在的 AppStorage 键,导致新主题值可能没有覆盖旧值。记录中的修复把深夜星空普通模式调整为浅色可读背景,改为显式覆盖动态令牌,并让未选中 Tab 使用主题次级文字色。
这段历史说明主题验收不能只看设置页预览。设置页色块正确,不代表 Tab、列表、弹窗和系统栏都消费了同一真源。建议把该问题固化为回归样例:选择深夜星空,遍历所有 Tab,核对背景、主要文字、次要文字、未选中状态和系统栏;再切到暗色并重复一次。历史构建成功只能证明当时编译完成,不能替代今天的视觉回归。
二十一、建议用完整快照收敛双轨颜色
这一节全部是建议实现。当前工程一边通过AppTheme和 AppStorage 传播字符串色,一边通过AppColors读取 base/dark 资源色。两套机制各自合理,但同时成为页面颜色来源时容易漂移。建议先定义用户偏好与实际模式的区别:
export type ColorModePreference = 'system' | 'light' | 'dark'; export interface ThemePreference { themeId: ThemeId; colorMode: ColorModePreference; }ThemePreference适合持久化,实际isDark则由系统配置和偏好共同计算。选择 system 时必须调用平台的COLOR_MODE_NOT_SET,不能只把本地布尔值改回浅色。建议将映射集中在唯一入口:
function resolvePlatformMode(pref: ColorModePreference): ConfigurationConstant.ColorMode { if (pref === 'light') return ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT; if (pref === 'dark') return ConfigurationConstant.ColorMode.COLOR_MODE_DARK; return ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET; }随后把一次主题解析的全部结果放进不可分割的快照。页面不应在背景用动态字符串、正文用资源色、边框又写硬编码值,而应消费同一快照中的语义角色:
export interface ThemeSnapshot { page: string; surface: string; surfaceAlt: string; primary: string; textPrimary: string; textSecondary: string; border: string; divider: string; systemBarContent: string; }快照字段必须覆盖真实页面需要的语义,而不是为了示例虚构无限令牌。错误、警告、成功、禁用、按下和选中态是否随主题变化,要由产品设计决定。若资源限定目录继续承担亮暗适配,可以由一个适配层读取资源并构造快照;若五套主题必须动态变化,则应从统一定义生成字符串快照与资源配置,避免人工维护两份不一致的颜色表。
持久化也需要明确顺序。当前源码没有把CURRENT_THEME写入DataStore,因此建议先同步读取最小偏好,再创建页面;读取失败时回退国风山水和系统模式,非法 ID 则通过getThemeById的受控降级处理:
const preference = loadThemePreferenceSync(); applyPlatformMode(preference.colorMode); const snapshot = resolveTheme(preference.themeId, currentSystemMode()); publishThemeSnapshot(snapshot);这只是时序示意,函数尚未存在。真正实现要避免启动阶段先发布默认主题、随后异步切换造成闪白,也要在保存失败时保持旧偏好并给出可诊断结果。主题设置页预览还应根据当前实际模式选择darkPrimaryColor、darkBackgroundColor和darkCardColor,不能始终展示亮色字段。
最后,对比度检查应该针对语义组合,而不是孤立色值。建议生成主题与模式笛卡尔积,至少覆盖五套主题的亮暗两态:
for (const theme of THEMES) { verifyContrast(resolveTheme(theme.id, false)); verifyContrast(resolveTheme(theme.id, true)); }verifyContrast仍是建议测试辅助函数。它应检查正文、次要文字、按钮、图标、边框、禁用态和系统栏的真实前景/背景组合,并结合页面截图处理背景图片、透明层和渐变等纯色公式无法判断的情况。
二十二、总结
时光清单当前主题主链路可以概括为:
Theme.ets 定义主题原始令牌 -> AppStore 结合 DARK_MODE 解析 -> AppStorage 广播语义颜色 -> 页面 @StorageLink 响应 -> 主窗口同步系统栏内容色这套结构已经解决了主题 ID 与颜色模式分离、页面背景集中更新、系统配置变化和状态栏可读性等关键问题。源码同时暴露出三个值得继续收敛的边界:组件内部仍有静态颜色、主题偏好尚未从可见代码中接入跨启动持久化、ThemeManager与ThemeService和AppStore存在职责重叠。
主题系统的验收标准不是“能切换五种颜色”,而是:任何主题与颜色模式组合下,语义一致、内容可读、系统区域协调,多页面只存在一个当前主题真源。
本文基于时光清单项目的Theme.ets、ThemeManager.ets、ThemeService.ets、AppStore.ets、EntryAbility.ets和主题设置页面真实源码复核整理。文中明确区分了当前已接入链路与建议改造,未将未使用抽象描述为已生效能力。
AI 辅助声明:本文在真实源码核验、结构梳理和文字编辑过程中使用了 AI 辅助;关键接口、引用关系和工程结论均以项目源码为依据进行人工复核。