【时光清单|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 辅助;关键接口、引用关系和工程结论均以项目源码为依据进行人工复核。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐