【时光清单|07】HarmonyOS ArkTS 主题系统实战:集中管理亮暗色和语义颜色
2026/9/2 8:37:54 网站建设 项目流程

【时光清单|07】HarmonyOS ArkTS 主题系统实战:集中管理亮暗色和语义颜色

HarmonyOS 应用的主题系统不是“准备几组背景色”这么简单。用户选择一种视觉主题后,页面背景、卡片、主色、正文、次要文字和系统栏必须一起变化;系统从亮色切到暗色时,当前主题仍要保留,只切换到对应的暗色令牌。如果每个页面自己判断isDark、自己拼十六进制颜色,最终一定会出现遗漏:主体已经变暗,弹窗仍是白色;页面背景已切换,状态栏图标却看不清;某个主题有暗色主色,另一个页面仍使用亮色常量。

时光清单的真实源码把主题模型定义在Theme.ets,由AppStore.applyTheme()根据主题 ID 和当前颜色模式解析语义色,再写入AppStorage。页面使用@StorageLink(StateKeys.THEME_BG)等键响应变化;EntryAbility.onConfigurationUpdate()监听系统颜色模式,并重新应用当前主题。项目里还存在ThemeManagerThemeService两个并行抽象,但主要页面实际调用的是AppStore,这一点必须先辨清。

本文沿真实调用链拆解主题定义、亮暗色解析、系统配置监听、状态栏适配、时间氛围背景与主题选择页面,同时指出当前语义色覆盖不完整、主题持久化尚未真正接通以及多套主题入口并存的工程风险。

本文将完成这些源码复核:

  1. 说明AppTheme为什么同时保存亮色与暗色令牌。
  2. 还原主题选择到所有页面刷新的真实链路。
  3. 分析系统深色模式变化如何重新解析当前主题。
  4. 解释状态栏内容色为什么要跟随背景亮度。
  5. 区分当前生效的AppStore与并存的ThemeManagerThemeService
  6. 给出语义色、持久化、对比度和多设备测试的渐进改造方案。

本文唯一标记: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

项目虽然有ThemeManagerThemeService,但从页面引用关系看,主题设置页直接调用:

.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); }

输入只有两个:themeIdisDark。输出是一组已经解析好的语义颜色。页面不再执行亮暗色分支,这是集中主题系统最核心的价值。

四、用 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点击处理只实现了darklight两个分支,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
  • 禁用态仍可识别,但不能与正常态混淆。
  • 选中不能只依赖颜色,还要有边框、图标或文案。

当前主题选择页用三段色块预览主色、背景和卡片色,这是很好的快速识别方式;选中项还显示“✓ 使用中”并增加边框,不只依赖色差。

但暗色预览尚未展示。更完善的预览可以在每个主题卡中同时显示亮、暗两组迷你色板,或者让预览跟随当前模式,避免用户在亮色环境选择主题后进入暗色才发现效果不合适。

十五、不要忘记图标、弹窗和系统区域

主题测试不能只看页面根背景。每套主题都要覆盖:

  1. 状态栏和导航栏。
  2. 底部导航选中与未选中态。
  3. 卡片、列表项、分割线和阴影。
  4. 对话框、底部弹窗和 Toast。
  5. 输入框、占位符、光标与错误提示。
  6. 空状态、加载态、失败态和禁用态。
  7. 图片上的文字与半透明遮罩。
  8. 可染色图标和不可染色位图。

如果图标本身是深灰色 PNG,切到深色卡片后可能消失。可染色资源应使用统一 tint 令牌;必须保留原色的位图,则要准备适配版本或稳定的承载底色。

十六、多设备与窗口变化测试

手机上主题切换通常只有一个页面可见,平板和 2in1 可能同时存在侧栏、列表和详情。所有区域必须订阅同一主题令牌,不能只有新打开页面使用新主题。

建议覆盖:

手机竖屏: 五套主题 × 亮色/暗色 手机横屏/小窗口: 长标题、底部安全区、弹窗 平板: 双栏同时切换,不出现一明一暗 2in1: 调整窗口尺寸后主题与系统栏不丢失 系统动态切换: 应用前台、后台恢复、配置更新

还要测试切换时页面是否闪白。首帧先显示默认主题、异步读取偏好后再切换,会产生明显闪烁。EntryAbility已经对心情背景采用同步初始化,这一思路也适用于外观偏好:在加载页面内容之前取得最小主题配置。

十七、异常与降级策略

主题系统不应因一个非法颜色让应用崩溃。建议定义清晰降级:

异常降级
未知主题 ID回退chinese_ink
非法颜色字符串使用默认语义令牌
状态栏更新失败记录日志,页面继续显示
背景图片缺失使用纯色背景
偏好读取失败默认跟随系统
主题保存失败保持旧主题并提示

当前updateStatusBarStyle()已通过try/catch避免窗口 API 失败影响页面,符合“装饰能力失败不阻断核心流程”的原则。但空catch不应遍布主题主链路,至少需要可诊断日志。

十八、渐进式整理方案

结合现有源码,最稳妥的改造顺序是:

  1. 明确AppStore为唯一主题写入口。
  2. 将页面中可变的AppColors替换为动态语义令牌。
  3. 新增ColorModePreference,支持系统、亮色、暗色三态。
  4. 使用现有DataStore持久化主题与颜色模式偏好。
  5. 启动时同步恢复偏好,避免首帧闪烁。
  6. 将自动背景逻辑迁到独立MoodService
  7. 清理未使用的ThemeManagerThemeServiceThemeConfig重复职责。
  8. 为每套主题建立对比度与多设备截图基线。

每一步都应保持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);

这只是时序示意,函数尚未存在。真正实现要避免启动阶段先发布默认主题、随后异步切换造成闪白,也要在保存失败时保持旧偏好并给出可诊断结果。主题设置页预览还应根据当前实际模式选择darkPrimaryColordarkBackgroundColordarkCardColor,不能始终展示亮色字段。

最后,对比度检查应该针对语义组合,而不是孤立色值。建议生成主题与模式笛卡尔积,至少覆盖五套主题的亮暗两态:

for (const theme of THEMES) { verifyContrast(resolveTheme(theme.id, false)); verifyContrast(resolveTheme(theme.id, true)); }

verifyContrast仍是建议测试辅助函数。它应检查正文、次要文字、按钮、图标、边框、禁用态和系统栏的真实前景/背景组合,并结合页面截图处理背景图片、透明层和渐变等纯色公式无法判断的情况。

二十二、总结

时光清单当前主题主链路可以概括为:

Theme.ets 定义主题原始令牌 -> AppStore 结合 DARK_MODE 解析 -> AppStorage 广播语义颜色 -> 页面 @StorageLink 响应 -> 主窗口同步系统栏内容色

这套结构已经解决了主题 ID 与颜色模式分离、页面背景集中更新、系统配置变化和状态栏可读性等关键问题。源码同时暴露出三个值得继续收敛的边界:组件内部仍有静态颜色、主题偏好尚未从可见代码中接入跨启动持久化、ThemeManagerThemeServiceAppStore存在职责重叠。

主题系统的验收标准不是“能切换五种颜色”,而是:任何主题与颜色模式组合下,语义一致、内容可读、系统区域协调,多页面只存在一个当前主题真源。


本文基于时光清单项目的Theme.etsThemeManager.etsThemeService.etsAppStore.etsEntryAbility.ets和主题设置页面真实源码复核整理。文中明确区分了当前已接入链路与建议改造,未将未使用抽象描述为已生效能力。

AI 辅助声明:本文在真实源码核验、结构梳理和文字编辑过程中使用了 AI 辅助;关键接口、引用关系和工程结论均以项目源码为依据进行人工复核。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询