在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

鸿蒙主题架构:暗色/亮色模式全局自适应/CustomTheme多品牌换肤方案工业级实践

一、前置思考

现代应用的"深色模式"已经不是可选项而是标配。HarmonyOS内置了darkMode系统能力,但在企业级应用中仅靠内置的暗色/亮色切换远远不够——多品牌定制(招商银行/工行不同色系)、多租户白标、活动主题动态切换,都要求在基础主题架构之上叠加更灵活的设计。

本文聚焦:

  • HarmonyOS原生主题机制(darkMode + color.json)的底层原理
  • 如何构建一套全局自适应、可动态切换、支持多品牌的主题架构
  • CustomTheme在复杂业务中的工业级实现

真实痛点场景:

  1. 多品牌白标:同一套代码要同时服务A银行(蓝色系)和B银行(红色系),不可能维护两个代码分支
  2. 活动换肤:春节红色主题、国庆金色主题,需要动态下发热更新而不发版
  3. 暗色模式不彻底:开发时只改了背景色,文字颜色没跟上,导致暗色下不可读
  4. 组件级主题隔离:某个页面需要独立的主题(如视频播放页强制暗色),不影响全局

二、核心原理

2.1 原生主题机制

HarmonyOS通过resources目录的分层实现主题切换:

resources/
├── base/
│   └── element/
│       ├── color.json   (基础颜色——亮色基准)
│       └── string.json  (基础文案)
├── dark/
│   └── element/
│       └── color.json   (暗色覆盖——与base同key,不同值)
└── rawfile/
    └── themes/
        ├── brand_a.json (品牌A的动态主题配置)
        └── brand_b.json (品牌B的动态主题配置)

工作流程

  1. 系统检测到用户开启了暗色模式
  2. 应用启动时,ArkUI框架读取dark/目录下的资源
  3. 所有使用$r('app.color.xxx')引用的颜色自动切换到暗色值
  4. 如果dark/中没有对应的key,fallback到base/的值

关键点:$r()引用的是编译时常量,颜色值在编译期就已确定,运行时无法动态修改。这就是为什么纯$r()方案无法实现运行时多品牌换肤。

2.2 颜色令牌(Design Tokens)体系

不要直接使用#FF0000这样的硬编码颜色,而是建立语义化令牌层:

品牌色系          →  brand_primary、brand_primary_light
功能色系(成功/警告/错误) →  functional_success、functional_warning、functional_error
中性色系(文字/背景/分割线) →  neutral_text_primary、neutral_bg、neutral_divider
表面色系(卡片/弹窗)     →  surface_card、surface_dialog、surface_overlay

令牌设计原则

  • 语义化命名:不是primary_blue而是brand_primary,方便切换品牌时不用改名
  • 三级粒度:primary → primary_light(hover态) → primary_dark(active态)
  • 亮暗分离:每个令牌在base/和dark/中各有一份定义
  • 不可直接使用:组件中始终引用令牌,不引用原始色值
// ❌ 硬编码——改一次要找遍所有文件
Text('标题').fontColor('#FFFFFF')

// ❌ 伪令牌——名字看起来规范,但值是死的
const titleColor = '#FFFFFF'

// ✅ 真正的令牌——值随主题变化
Text('标题').fontColor(this.theme.textPrimary)

2.3 运行时主题 vs 编译时主题

维度 编译时主题 ($r) 运行时主题 (@State/AppStorage)
切换方式 系统设置 → 重启应用 应用内点击 → 即时生效
响应速度 需重建组件 状态变更 → 组件自动刷新
支持场景 亮/暗切换 任意品牌色切换
限制 颜色固定 需手动管理状态传递

结论:生产环境中需要两者结合——亮/暗基础色用$r()兜底,品牌换肤用运行时状态覆盖。

三、设计令牌体系完整实施

3.1 令牌定义层

首先定义品牌主题的数据结构(对应Demo中的BrandTheme接口):

interface BrandTheme {
  name: string;          // 主题名称,如"蓝色科技"
  primary: string;       // 主色
  primaryLight: string;  // 主色浅色(hover/选中背景)
  secondary: string;     // 辅色
  accent: string;        // 强调色
  bg: string;           // 页面背景
  bgCard: string;        // 卡片背景
  text: string;          // 主文字
  textSecondary: string; // 辅助文字
  success: string;       // 成功色
  warning: string;       // 警告色
  error: string;         // 错误色
}

3.2 多品牌色彩配置

实战中定义多套品牌色:

const BRAND_THEMES: Record<string, BrandTheme> = {
  'purple': {
    name: '紫色(默认)',
    primary: '#CE93D8', primaryLight: '#E1BEE7',
    secondary: '#7C4DFF', accent: '#B388FF',
    bg: '#1A1A2E', bgCard: '#16213E',
    text: '#FFFFFF', textSecondary: 'rgba(255,255,255,0.5)',
    success: '#69F0AE', warning: '#FFD54F', error: '#FF5252'
  },
  'blue': {
    name: '蓝色科技',
    primary: '#4FC3F7', primaryLight: '#B3E5FC',
    secondary: '#0288D1', accent: '#03A9F4',
    bg: '#0D1B2A', bgCard: '#1B2838',
    text: '#FFFFFF', textSecondary: 'rgba(255,255,255,0.5)',
    success: '#69F0AE', warning: '#FFD54F', error: '#FF5252'
  },
  'green': {
    name: '绿色自然',
    primary: '#81C784', primaryLight: '#C8E6C9',
    secondary: '#388E3C', accent: '#4CAF50',
    bg: '#0D1A0D', bgCard: '#1B2E1B',
    text: '#FFFFFF', textSecondary: 'rgba(255,255,255,0.5)',
    success: '#69F0AE', warning: '#FFD54F', error: '#FF5252'
  },
  'orange': {
    name: '橙色活力',
    primary: '#FFB74D', primaryLight: '#FFE0B2',
    secondary: '#E65100', accent: '#FF9800',
    bg: '#1A140D', bgCard: '#2E211B',
    text: '#FFFFFF', textSecondary: 'rgba(255,255,255,0.5)',
    success: '#69F0AE', warning: '#FFD54F', error: '#FF5252'
  }
};

设计要点

  • 功能色(success/warning/error)四套品牌中保持一致,因为这些是通用语义,换色反而造成用户困惑
  • 背景色系需要和主色保持协调——紫色主色配深紫黑背景,蓝色主色配深蓝黑背景
  • 文字色在深色背景上统一白色系,通过透明度区分层级

四、运行时主题切换完整实现

4.1 全局状态管理

使用AppStorage作为全局主题状态的"单一真相源":

// 全局存储当前品牌key
AppStorage.setOrCreate('currentBrand', 'purple');

// 全局暗色/亮色模式
AppStorage.setOrCreate('isDarkMode', false);

4.2 组件级主题消费

组件通过@Local@StorageLink获取当前主题:

@Entry
@ComponentV2
struct ThemeArchitectureDemo {
  @Local darkMode: boolean = false;       // 暗色模式开关
  @Local brandKey: string = 'purple';     // 当前品牌key
  @Local fontSize: number = 14;           // 字体大小(可扩展令牌)
  @Local radius: number = 12;             // 圆角半径(可扩展令牌)

  // 根据brandKey实时计算当前主题色
  private get currentTheme(): BrandTheme {
    const theme: BrandTheme | undefined = BRAND_THEMES[this.brandKey];
    if (theme !== undefined) {
      return theme;
    }
    return BRAND_THEMES['purple']; // fallback
  }
}

关键设计currentTheme是一个计算属性(getter),它不存储状态,而是每次访问时根据brandKey动态计算。当@Local brandKey变化时,所有依赖currentTheme的UI都会自动刷新——这是ArkUI响应式系统的核心优势。

4.3 主题切换事件流

用户点击品牌按钮
  → this.brandKey = 'blue'     (@Local状态变更)
  → build() 自动重新执行       (ArkUI响应式驱动)
  → currentTheme getter返回蓝色主题 (计算属性更新)
  → 所有 .backgroundColor(currentTheme.bgCard) 的组件自动更新颜色
  → 用户看到界面秒级切换

是的,不需要手动遍历组件、不需要通知、不需要事件总线。 这就是声明式UI+响应式状态的威力。

4.4 暗色模式与品牌色叠加

暗色模式和品牌色是两个正交维度,需要叠加处理:

// 获取实际渲染的颜色(考虑暗色叠加)
private getColor(baseColor: string): string {
  if (!this.darkMode) {
    return baseColor;
  }
  // 暗色模式下降低背景亮度、提高文字对比度
  // 简化处理:品牌色不变,背景色加深
  return baseColor;
}

对于更精细的控制,可以为每种品牌色定义其暗色变体:

interface BrandTheme {
  primary: string;
  primaryDark: string;   // ← 暗色模式下的主色
  bg: string;
  bgDark: string;        // ← 暗色模式下的背景
  // ...
}

4.5 主题配置持久化

使用Preferences将用户选择的品牌和模式保存到本地:

import { preferences } from '@kit.ArkData';

async function saveThemePreference(brandKey: string, darkMode: boolean): Promise<void> {
  const prefs: preferences.Preferences =
    await preferences.getPreferences(getContext(), 'theme_settings');
  await prefs.put('brandKey', brandKey);
  await prefs.put('darkMode', darkMode);
  await prefs.flush();
}

async function loadThemePreference(): Promise<void> {
  const prefs: preferences.Preferences =
    await preferences.getPreferences(getContext(), 'theme_settings');
  const brandKey: string = prefs.get('brandKey', 'purple') as string;
  const darkMode: boolean = prefs.get('darkMode', false) as boolean;
  AppStorage.setOrCreate('currentBrand', brandKey);
  AppStorage.setOrCreate('isDarkMode', darkMode);
}

启动流程aboutToAppear()loadThemePreference() → 设置全局状态 → UI自动以保存的主题渲染。

五、高级主题扩展

5.1 字体主题化

除了颜色,字体大小也可以令牌化,满足无障碍和老年模式需求:

interface FontTokens {
  caption: number;   // 10vp 说明文字
  body: number;      // 14vp 正文
  subtitle: number;  // 18vp 标题
  title: number;     // 22vp 大标题
  display: number;   // 28vp 展示标题
}

const FONT_TOKENS: Record<string, FontTokens> = {
  'small':  { caption: 9, body: 12, subtitle: 16, title: 20, display: 24 },
  'normal': { caption: 10, body: 14, subtitle: 18, title: 22, display: 28 },
  'large':  { caption: 12, body: 16, subtitle: 20, title: 26, display: 32 }
};

5.2 圆角主题化

不同品牌可能有不同的圆角风格:

const RADIUS_TOKENS: Record<string, number> = {
  'sharp': 4,    // 锐利风格(科技类应用)
  'normal': 12,  // 标准圆角
  'round': 20    // 大圆角(社交/娱乐类应用)
};

5.3 活动主题动态下发

对于无需发版的活动换肤,可以通过远端配置下发主题色:

interface RemoteThemeConfig {
  version: string;      // 配置版本号
  brandKey: string;     // 基于哪个品牌色
  overrides: Record<string, string>; // 覆盖的颜色值
  validFrom: string;    // 生效时间
  validUntil: string;   // 失效时间
}

// 从远端拉取并叠加到当前主题
async function applyRemoteTheme(config: RemoteThemeConfig): Promise<void> {
  const baseTheme: BrandTheme = BRAND_THEMES[config.brandKey];
  // 合并覆盖
  const merged: Record<string, string> = {};
  const keys: string[] = Object.keys(baseTheme);
  for (let i: number = 0; i < keys.length; i++) {
    const key: string = keys[i];
    merged[key] = config.overrides[key] !== undefined
      ? config.overrides[key]
      : (baseTheme as Record<string, Object>)[key] as string;
  }
  // 存入AppStorage供全局使用
  AppStorage.setOrCreate('remoteTheme', merged);
}

六、完整代码架构

Demo中的主题架构分层:

Layer 1: 设计令牌 (BrandTheme接口)
  ├── primary / primaryLight / secondary / accent
  ├── bg / bgCard / text / textSecondary
  └── success / warning / error

Layer 2: 品牌策略 (BRAND_THEMES)
  ├── purple(紫色默认)
  ├── blue(蓝色科技)
  ├── green(绿色自然)
  └── orange(橙色活力)

Layer 3: 主题状态 (AppStorage + @Local)
  ├── currentBrand → 驱动品牌切换
  ├── isDarkMode   → 驱动亮暗切换
  └── currentTheme (getter) → 驱动UI渲染

Layer 4: 组件消费
  └── .backgroundColor(currentTheme.bgCard)
     .fontColor(currentTheme.text)
     .fontSize(fontSize)

七、避坑速查

现象 原因 解决
dark/color.json未同步 暗色下背景黑+文字黑=不可读 dark/缺少对应key,fallback到base的亮色值 每次新增颜色令牌,必须在dark/中同步添加
$r()动态主题不生效 改AppStorage后颜色不变 $r()是编译时常量,运行时不可变 运行时主题必须用@State/@StorageLink传递颜色值
图片资源主题化 暗色下图标看不清 位图颜色固定,不随主题切换 使用fillColor、SVG双色图标或提供两套图片资源
跨页面主题不同步 切页面后主题还原 仅在单页面@State中管理主题 AppStorage全局存储主题状态
暗色模式闪烁 启动时先亮后暗 从Preferences加载有延迟 aboutToAppear最早时机加载,或设默认暗色值
组件级主题隔离 某页面需独立主题被覆盖 AppStorage全局唯一 页面级@Local覆盖全局值,提供@Provide局部注入
darkMode感知延迟 navPathStack切换时主题失 系统切换有回调延迟 onConfigurationUpdate监听系统主题变更
色值透明度陷阱 rgba(255,255,255,0.5)在白色背景不可见 透明度依赖于背景色 高亮背景用黑色半透明,暗色背景用白色半透明
主题切换动画闪烁 切换瞬间颜色跳变 颜色不支持transition animateTo包裹主题切换,或加fade过渡层
深色模式下阴影失效 阴影在暗背景上不可见 阴影颜色是黑色 暗色模式下用发光(border+blur)代替阴影

八、总结

主题架构的核心不是黑白色切换,而是建立一套可扩展的设计令牌体系

  1. 令牌先行,品牌后配:先定义语义令牌(brand_primary、neutral_text),再为每个品牌填充色值
  2. 编译时+运行时双轨:亮暗用$r()兜底,品牌用@State/AppStorage覆盖
  3. 状态即主题:品牌key是状态、darkMode是状态,ArkUI响应式系统自动完成UI更新
  4. 做减法:不要试图设计"万能主题系统",覆盖当前需要的维度即可——颜色、字体、圆角

好的主题架构让品牌定制的边际成本趋近于零。一个银行App的紫色换蓝色,成本应该是改一个JSON配置,而不是改100个.ets文件。

对应Demo文件:entry/src/main/ets/pages/ThemeArchitectureDemo.ets

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐