【时光清单|07】HarmonyOS ArkTS 主题系统实战:集中管理亮暗色和语义颜色
【时光清单|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 辅助;关键接口、引用关系和工程结论均以项目源码为依据进行人工复核。
更多推荐




所有评论(0)