一、前言:品牌换肤的"组合爆炸"

先看一个真实的业务场景。

你的应用要上线企业版和个人版,企业版用品牌蓝,个人版用活力粉。之后又要加一个咖啡品牌合作主题、一个环保绿色主题。每个主题还要同时支持浅色和深色模式。

传统做法:

// 每个页面都写品牌判断
Text('标题').fontColor(brand === 'pink' ? '#D81B60' : '#0A59F7')

// 加一个品牌就要改所有页面
Text('标题').fontColor(
  brand === 'pink' ? '#D81B60' :
  brand === 'coffee' ? '#6F4E37' :
  brand === 'green' ? '#2E7D32' :
  '#0A59F7'
)

这导致两个严重问题:

  1. 组合爆炸:5 个品牌 × 2 种亮度 = 10 种组合,每个页面都要写一堆三元表达式。

  2. 改一处动全身:加一个品牌(比如紫色),要打开每个页面文件改一遍颜色判断。

E012 的研究问题就是:

能否在已有的浅色/深色地基之上,支持无限品牌主题,且不修改任何业务页面代码?

答案是:可以。关键在于把"品牌"和"结构"正交分离,用语义令牌做映射,让页面代码永远不知道当前是什么品牌。


二、五层主题架构

E012 把鸿蒙的主题能力整理成一个五层模型:

Layer 0  HarmonyOS Runtime
         $r('sys.color.*') 自动跟随系统 colorMode 切换深浅色

Layer 1  Brightness(HarmonyOS 拥有)
         WithTheme.colorMode: SYSTEM / LIGHT / DARK
         来源:AppStorage(BRIGHTNESS_STORAGE_KEY) → ThemeEngine.buildColorMode()

Layer 2  Brand(ArkUILab 拥有)
         WithTheme.theme: CustomTheme with CustomColors overrides
         来源:AppStorage(BRAND_STORAGE_KEY) → ThemeEngine.buildCustomTheme()
         品牌:Default / Pink / Coffee / Green / Purple

Layer 3  Semantic Token(ArkUILab 拥有)
         ThemeTokens.ets
         - 结构锚定:ThemePageBackground / ThemeTextPrimary / ...(E011,不变)
         - 品牌锚定:ThemeBrandAccent / ThemeBrandButtonBackground / ThemeBrandGradientStart/End(E012 新增)

Layer 4  Component Token(ArkUILab 拥有,部分)
         NavigationTitleBarTokens.ets
         未来:每个框架的专用令牌

Layer 5  Business / Demo
         内容身份色、数据可视化、演示渐变

谁拥有什么

  • HarmonyOS 拥有:Layer 0 运行时、Layer 1 亮度解析、Layer 2 通过 WithTheme 自动传播品牌到 HDS 内部组件。

  • ArkUILab 拥有:Layer 2 品牌调色板定义、Layer 2 AppStorage 状态管理(驱动自定义组件)、Layer 3-5 全部。

最关键的判断

品牌只管品牌令牌(accent、button、gradient),结构令牌(background、text、surface、divider)永远不变。 品牌和结构正交,任意品牌可以和任意亮度组合。

Default + Dark:   蓝色 accent  + 深色结构
Pink + Dark:      粉色 accent  + 深色结构
Coffee + Light:   棕色 accent  + 浅色结构

三、双轨品牌传播机制

这是 E012 最核心的技术发现。鸿蒙的 WithTheme 可以自动把品牌色传播给 HDS 内部组件,但不能自动传播给 ArkUILab 自定义组件。必须用"双轨"机制:

3.1 轨道一:WithTheme 自动传播(HDS 内部组件)

WithTheme({ theme: pinkCustomTheme, colorMode: SYSTEM })
  → HDS Button 自动变粉
  → HDS TextField 自动变粉
  → HDS Navigation bar 自动变粉

WithTheme 在其作用域内,自动把 CustomColors 应用到所有 HDS 内部组件。你只需要在根组件包一层 WithTheme

3.2 轨道二:AppStorage + @StorageProp(自定义组件)

但 ArkUILab 的自定义组件用的是 ThemeTokens.ets 里的 $r('sys.color.*')$r('app.color.*')——这些是静态资源引用,在渲染时解析一次,不会响应 WithTheme 的品牌变化。

解法是用 AppStorage + @StorageProp 触发重新渲染,加上读取 AppStorage 的品牌感知令牌函数:

ThemePlayground 选择 Pink
  ↓
ThemeEngine.setBrand(BrandTheme.Pink)
  ↓
AppStorage(BRAND_STORAGE_KEY) 更新
  ↓
所有声明了 @StorageProp(BRAND_STORAGE_KEY) 的组件触发 rebuild
  ↓
build() 调用 ThemeBrandAccent() → 读 AppStorage → 返回粉色 hex
  ↓
WithTheme 也更新了(根组件的 @StorageProp)
  ↓
HDS 内部组件通过 WithTheme 作用域拿到新的 CustomColors

3.3 双轨对照表

组件类型

品牌响应方式

HDS 内部组件(Button 等)

WithTheme 自动

HdsTabs BottomTabBarStyle

不自动! 需要显式传 .labelStyle({ selectedColor }).iconStyle({ selectedColor })

ArkUILab 自定义组件

AppStorage + @StorageProp 重新渲染 + 品牌感知令牌函数


四、BrandTheme 实战:五种品牌的定义

4.1 品牌枚举和亮度枚举

export const BRAND_STORAGE_KEY: string = 'arkuilab_active_brand';
export const BRIGHTNESS_STORAGE_KEY: string = 'arkuilab_active_brightness';

export enum BrandTheme {
  Default = 0,
  Pink = 1,
  Coffee = 2,
  Green = 3,
  Purple = 4
}

export enum ThemeBrightness {
  System = 0,
  Light = 1,
  Dark = 2
}

两个 Storage Key 用常量导出,永远不要在业务代码里用裸字符串

4.2 CustomColors 实现(HDS 内部组件品牌色)

每种品牌需要实现 CustomColors 接口,这是 WithTheme 传播给 HDS 组件用的:

class PinkColors implements CustomColors {
  brand: ResourceColor = '#FFD81B60';
  backgroundEmphasize: ResourceColor = '#FFD81B60';
  compEmphasizePrimary: ResourceColor = '#FFD81B60';
  compEmphasizeSecondary: ResourceColor = '#33D81B60';
  iconEmphasize: ResourceColor = '#FFD81B60';
  fontEmphasize: ResourceColor = '#FFD81B60';
}

每种品牌一个 Colors 类,覆盖 brandbackgroundEmphasizecompEmphasizePrimary 等 6 个核心令牌。CustomColors 接口实际有 40+ 个可覆盖令牌,但 v1 只覆盖最核心的几个。

4.3 BrandPaletteEntry(自定义组件品牌色)

每种品牌还需要一个 BrandPaletteEntry,这是 ArkUILab 自定义组件通过 AppStorage 读取品牌色用的:

export interface BrandPaletteEntry {
  name: string;
  description: string;
  accentLight: string;
  accentDark: string;
  buttonBgLight: string;
  buttonBgDark: string;
  gradientStartLight: string;
  gradientEndLight: string;
  gradientStartDark: string;
  gradientEndDark: string;
  customTheme: CustomTheme;
}

class PinkPaletteEntry implements BrandPaletteEntry {
  name: string = '粉色';
  description: string = '玫瑰粉色。生活方式和消费者应用程序的充满活力的品牌表达。';
  accentLight: string = '#FFD81B60';
  accentDark: string = '#FFE91E8C';
  buttonBgLight: string = '#FFD81B60';
  buttonBgDark: string = '#FFE91E8C';
  gradientStartLight: string = '#FFE4F0';
  gradientEndLight: string = '#FCE4EC';
  gradientStartDark: string = '#3D0A1E';
  gradientEndDark: string = '#2A0515';
  customTheme: CustomTheme = new BrandCustomTheme(new PinkColors());
}

注意每个颜色都有 Light 和 Dark 两个版本——品牌色在深色模式下通常需要更亮(如粉色 #D81B60#E91E8C),渐变在深色模式下需要更暗(如 #FFE4F0#3D0A1E)。

4.4 品牌调色板注册

所有品牌注册到一个 Map:

export const BRAND_PALETTE: Map<BrandTheme, BrandPaletteEntry> = new Map<BrandTheme, BrandPaletteEntry>([
  [BrandTheme.Default, new DefaultPaletteEntry()],
  [BrandTheme.Pink, new PinkPaletteEntry()],
  [BrandTheme.Coffee, new CoffeePaletteEntry()],
  [BrandTheme.Green, new GreenPaletteEntry()],
  [BrandTheme.Purple, new PurplePaletteEntry()]
]);

五、ThemeEngine 实战:主题状态管理

5.1 ThemeEngine 静态类

ThemeEngine 是品牌主题的中央状态管理器:

export class ThemeEngine {
  // 初始化 AppStorage 默认值
  static init(): void {
    if (!AppStorage.has(BRAND_STORAGE_KEY)) {
      AppStorage.setOrCreate<BrandTheme>(BRAND_STORAGE_KEY, BrandTheme.Default);
    }
    if (!AppStorage.has(BRIGHTNESS_STORAGE_KEY)) {
      AppStorage.setOrCreate<ThemeBrightness>(BRIGHTNESS_STORAGE_KEY, ThemeBrightness.System);
    }
  }

  // 品牌 getter/setter
  static activeBrand(): BrandTheme {
    return AppStorage.get<BrandTheme>(BRAND_STORAGE_KEY) ?? BrandTheme.Default;
  }

  static setBrand(brand: BrandTheme): void {
    AppStorage.setOrCreate<BrandTheme>(BRAND_STORAGE_KEY, brand);
  }

  // 亮度 getter/setter(需要 context 来驱动系统 colorMode)
  static setBrightness(brightness: ThemeBrightness, context?: Context): void {
    AppStorage.setOrCreate<ThemeBrightness>(BRIGHTNESS_STORAGE_KEY, brightness);
    if (context !== undefined) {
      try {
        context.getApplicationContext().setColorMode(ThemeEngine._toConfigColorMode(brightness));
      } catch (_e) {
        // setColorMode 是 best-effort
      }
    }
  }

  // 构建 WithTheme 需要的 CustomTheme
  static buildCustomTheme(brand: BrandTheme): CustomTheme | undefined {
    if (brand === BrandTheme.Default) {
      return undefined;   // Default 不应用 CustomTheme,保留系统原色
    }
    const entry: BrandPaletteEntry | undefined = BRAND_PALETTE.get(brand);
    return entry?.customTheme;
  }

  // 构建 WithTheme 需要的 ThemeColorMode
  static buildColorMode(brightness: ThemeBrightness): ThemeColorMode {
    switch (brightness) {
      case ThemeBrightness.Light: return ThemeColorMode.LIGHT;
      case ThemeBrightness.Dark: return ThemeColorMode.DARK;
      default: return ThemeColorMode.SYSTEM;
    }
  }
}

5.2 根组件初始化

在根组件(ApplicationShellIndex)的 aboutToAppear 里初始化:

aboutToAppear(): void {
  ThemeEngine.init();
}

5.3 根组件包裹 WithTheme

@StorageProp(BRAND_STORAGE_KEY) private activeBrand: BrandTheme = BrandTheme.Default;
@StorageProp(BRIGHTNESS_STORAGE_KEY) private activeBrightness: ThemeBrightness = ThemeBrightness.System;

build() {
  WithTheme({
    theme: ThemeEngine.buildCustomTheme(this.activeBrand),
    colorMode: ThemeEngine.buildColorMode(this.activeBrightness)
  }) {
    // 应用内容
    ApplicationShell()
  }
}

关键Default 品牌返回 undefined,表示不应用任何 CustomTheme,保留 HarmonyOS 系统原色行为。


六、品牌感知令牌:ThemeBrandAccent 等

6.1 品牌令牌函数

E012 在 ThemeTokens.ets 新增了四个品牌感知令牌函数,它们在渲染时读取 AppStorage:

function _activeBrandEntry(): BrandPaletteEntry | undefined {
  const brand: BrandTheme = AppStorage.get<BrandTheme>(BRAND_STORAGE_KEY) ?? BrandTheme.Default;
  return BRAND_PALETTE.get(brand);
}

function _isDark(): boolean {
  const brightness: ThemeBrightness = AppStorage.get<ThemeBrightness>(BRIGHTNESS_STORAGE_KEY) ?? ThemeBrightness.System;
  return brightness === ThemeBrightness.Dark;
}

export function ThemeBrandAccent(): ResourceColor {
  const entry: BrandPaletteEntry | undefined = _activeBrandEntry();
  if (entry === undefined) {
    return $r('sys.color.ohos_id_color_primary');
  }
  return _isDark() ? entry.accentDark : entry.accentLight;
}

export function ThemeBrandButtonBackground(): ResourceColor {
  const entry: BrandPaletteEntry | undefined = _activeBrandEntry();
  if (entry === undefined) {
    return $r('sys.color.ohos_id_color_activated');
  }
  return _isDark() ? entry.buttonBgDark : entry.buttonBgLight;
}

export function ThemeBrandGradientStart(): ResourceColor { /* 同上 */ }
export function ThemeBrandGradientEnd(): ResourceColor { /* 同上 */ }

6.2 什么时候用什么令牌

场景

正确令牌

页面背景

ThemePageBackground() ← 结构令牌

卡片表面

ThemeSurfaceBackground() ← 结构令牌

主要文字

ThemeTextPrimary() ← 结构令牌

分割线

ThemeDividerColor() ← 结构令牌

品牌色图标 / 激活指示 / 选中态

ThemeBrandAccent() ← 品牌令牌

填充主按钮

ThemeBrandButtonBackground() ← 品牌令牌

Hero 渐变横幅

ThemeBrandGradientStart/End() ← 品牌令牌

系统蓝按钮(非品牌)

ThemePrimaryButtonBackground() ← 结构令牌


七、⚠️ 踩坑一:@Builder 响应式陷阱

这是 E012 最隐蔽、最耗时的坑。很多人声明了 @StorageProp 却发现品牌色还是不更新。

7.1 症状

ThemePlayground 切换品牌后,使用 ThemeBrandAccent() 的自定义组件不更新,即使组件已经声明了 @StorageProp(BRAND_STORAGE_KEY)

7.2 根因

ArkTS 的 @Builder 依赖追踪只识别 Builder 体内对 this.xxx 状态变量的直接读取@State / @StorageProp / @Prop / @Link)。它不会通过普通模块级函数调用传播响应式

品牌令牌函数(ThemeBrandAccent() 等)是模块级函数,内部读 AppStorage.get()。但在 @Builder 里,编译器把它们当作"返回常量的表达式",永不重新执行 Builder

7.3 修复:用实例方法读 this.activeBrand

// ❌ 错误:Builder 内没有读 this.xxx,品牌色永不更新
@Builder
private BrandChip() {
  Column().backgroundColor(ThemeBrandAccent())
}

// ✅ 正确:实例方法先读 this.activeBrand,建立 @StorageProp 依赖
private brandAccentColor(): ResourceColor {
  const brand = this.activeBrand;           // ← 建立 @StorageProp 依赖
  const brightness = this.activeBrightness;  // ← 可选,用于深浅色变体
  return ThemeBrandAccent();
}

@Builder
private BrandChip() {
  Column().backgroundColor(this.brandAccentColor())
}

关键:在实例方法体内读一行 this.activeBrand,就能建立依赖关系,让 @Builder 在品牌变化时重新执行。

7.4 调试清单

当品牌色不更新时,按顺序检查:

  1. @StorageProp(BRAND_STORAGE_KEY) 声明了没?

  2. 令牌是在 @Builder 里调用的吗?

  3. 如果在 @Builder 里,Builder 体内有没有读 this.xxx?没有就包一层实例方法。


八、⚠️ 踩坑二:BottomTabBarStyle 不继承 WithTheme

这是 E012 第二耗时的坑。

8.1 症状

切换品牌后,HdsTabs 浮动底栏的选中色永远是系统蓝,不跟随品牌。

8.2 根因

BottomTabBarStyle.of(icon, text) 不读 WithTheme 作用域里的 CustomColors.brand。它在 SDK 里硬编码了 #FF007DFF 作为默认 selectedColor(在 tab_content.d.ts 中确认)。

这意味着:不管你怎么配 WithTheme、怎么加 @StorageProp、怎么强制重挂载子树,底栏选中色都不会变。

8.3 被拒绝的方案:强制重挂载 HdsTabs

E012 尝试了用 ForEach + @Watch 递增 epoch 来强制重挂载 HdsTabs。构建通过但真机测试后拒绝

重挂载会销毁整个 HdsTabs 子树(包括 NavigationAppShell 子组件),导致 NavPathStack 重放,在用户停留在 ThemePlayground 页面时每次品牌变化都把当前页面重新 push 一遍。太破坏性

8.4 正确修复:显式传 labelStyle / iconStyle

既然 BottomTabBarStyle 不读 WithTheme,就直接把品牌色喂给它:

private brandAccentColor(): ResourceColor {
  const brand = this.activeBrand;            // ← 同样要读 this.xxx 建立依赖
  const brightness = this.activeBrightness;
  return ThemeBrandAccent();
}

private BottomTab(icon: Resource, text: string): BottomTabBarStyle {
  return BottomTabBarStyle.of(icon, text)
    .padding({ left: 4, right: 4, top: 4, bottom: 4 })
    .layoutMode(LayoutMode.VERTICAL)
    .labelStyle({ selectedColor: this.brandAccentColor() })   // ← 品牌色
    .iconStyle({ selectedColor: this.brandAccentColor() });   // ← 品牌色
}

规则

BottomTabBarStyle 不继承 WithTheme。迁移 HdsTabs 底栏品牌色时,必须通过 .labelStyle({ selectedColor }).iconStyle({ selectedColor }) 显式传入。


九、亮度切换:为什么必须调 setColorMode

9.1 WithTheme.colorMode vs ApplicationContext.setColorMode

这两个 API 影响的范围完全不同

API

影响范围

WithTheme.colorMode

WithTheme 作用域内的 HDS 内部组件

ApplicationContext.setColorMode()

全 App$r('sys.color.*')$r('app.color.*') 资源解析

如果你只设了 WithTheme.colorMode,HDS 组件变暗了,但你的页面背景($r('sys.color.*'))还是浅色——因为系统级 colorMode 没变。

9.2 正确做法

ThemeEngine.setBrightness() 同时做两件事:

static setBrightness(brightness: ThemeBrightness, context?: Context): void {
  // 1. 更新 AppStorage → 触发 @StorageProp 重渲染 → 品牌渐变令牌拿到 dark/light 变体
  AppStorage.setOrCreate<ThemeBrightness>(BRIGHTNESS_STORAGE_KEY, brightness);

  // 2. 调用系统级 setColorMode → 切换 $r() 资源解析
  if (context !== undefined) {
    try {
      context.getApplicationContext().setColorMode(ThemeEngine._toConfigColorMode(brightness));
    } catch (_e) { /* best-effort */ }
  }
}

调用时需要传 context:

const ctx: Context | undefined = this.getUIContext().getHostContext();
ThemeEngine.setBrightness(brightness, ctx);

如果只调 setBrightness(brightness) 不传 context,只有 AppStorage 状态更新了,$r() 资源色不会变。


十、新增品牌的七步零改动流程

E012 最强的能力:新增一个品牌(比如 MOKA),零页面代码改动

七步流程

1. 在 BrandTheme 枚举加一个值

export enum BrandTheme {
  Default = 0,
  Pink = 1,
  Coffee = 2,
  Green = 3,
  Purple = 4,
  MOKA = 5    // 新增
}

2. 实现 CustomColors(给 HDS 组件用):

class MOKAColors implements CustomColors {
  brand: ResourceColor = '#FF2D7DD2';
  backgroundEmphasize: ResourceColor = '#FF2D7DD2';
  compEmphasizePrimary: ResourceColor = '#FF2D7DD2';
  compEmphasizeSecondary: ResourceColor = '#332D7DD2';
  iconEmphasize: ResourceColor = '#FF2D7DD2';
  fontEmphasize: ResourceColor = '#FF2D7DD2';
}

3. 实现 BrandPaletteEntry(给自定义组件用):

class MOKAPaletteEntry implements BrandPaletteEntry {
  name: string = 'MOKA';
  description: string = 'MOKA brand theme.';
  accentLight: string = '#FF2D7DD2';
  accentDark: string = '#FF4A9EE8';
  buttonBgLight: string = '#FF2D7DD2';
  buttonBgDark: string = '#FF4A9EE8';
  gradientStartLight: string = '#E3F2FD';
  gradientEndLight: string = '#E8F5E9';
  gradientStartDark: string = '#0D2840';
  gradientEndDark: string = '#0D2E10';
  customTheme: CustomTheme = new BrandCustomTheme(new MOKAColors());
}

4. 加入 BRAND_PALETTE Map

[BrandTheme.MOKA, new MOKAPaletteEntry()]

5. 加入 ThemeEngine.allBrands()

static allBrands(): BrandTheme[] {
  return [BrandTheme.Default, BrandTheme.Pink, BrandTheme.Coffee,
          BrandTheme.Green, BrandTheme.Purple, BrandTheme.MOKA];
}

6. 在 base/dark color.json 加品牌色资源

{ "name": "arkuilab_brand_moka_accent", "value": "#FF2D7DD2" },
{ "name": "arkuilab_brand_moka_button", "value": "#FF2D7DD2" }

7. 构建验证

完成。不需要改任何页面或框架代码。ThemePlayground 自动显示新品牌选项,所有用了品牌令牌的组件自动响应。


十一、品牌主题能力地图

Theme Engine v1 能力覆盖

├── Brightness(亮度)                    ✓ 完成
│   ├── System(跟随系统)
│   ├── Light(强制浅色)
│   └── Dark(强制深色)
│
├── Brand(品牌)                         ✓ 完成
│   ├── Default(HarmonyOS 系统蓝)
│   ├── Pink(玫瑰粉)
│   ├── Coffee(烤成棕)
│   ├── Green(森林绿)
│   └── Purple(深紫罗兰)
│
├── Semantic Token(语义令牌)             ✓ 完成
│   ├── 结构令牌(E011)
│   └── 品牌令牌(E012)
│
├── Typography(字体)                    ✗ 未来
├── Radius(圆角)                        ✗ 未来
├── Shadow(阴影)                        ~ 部分(ThemeCardShadowColor)
├── Material(材质)                      ~ 部分(API 26 systemMaterial 待定)
└── Motion(动效)                        ✗ 未来

Typography、Radius、Motion 属于更广义的 Design System 范畴,留给未来实验。


十二、框架品牌就绪审计

框架

品牌状态

需要迁移吗

Navigation(HDS 内部)

WithTheme 自动

FloatingTabBar

E012 完成

已完成

ApplicationShell

E012 完成

已完成

Overlay Framework

仅结构令牌

Menu Framework

仅结构令牌 + 系统色

Selection Framework

仅结构令牌

Feedback Framework

仅结构令牌

Search Framework

仅结构令牌

关键结论:只用了结构令牌的框架不需要品牌迁移——结构令牌(页面背景、文字、分割线)不受品牌影响,它们通过系统资源自动跟随深浅色。


十三、最佳实践清单

13.1 令牌选择

  • ✅ 品牌 iconName / link 文字 / 激活指示 / 选中态用 ThemeBrandAccent()

  • ✅ 填充主按钮用 ThemeBrandButtonBackground(),搭配对比度足够的前景色。

  • ✅ Hero 横幅 / 色调标题 / 特色卡片用 ThemeBrandGradientStart/End()

  • ✅ 结构 UI(背景、文字、表面、分割线、阴影、蒙层)必须用结构令牌,不用品牌令牌。

13.2 @StorageProp 规则

  • ✅ 用了品牌令牌的组件必须声明 @StorageProp(BRAND_STORAGE_KEY)

  • ✅ 品牌令牌在 @Builder 内调用时,必须用实例方法先读 this.activeBrand

  • ✅ 只用结构令牌的组件不需要 @StorageProp——它们通过系统资源自动更新。

  • ✅ 永远用导出的常量 BRAND_STORAGE_KEY,不用裸字符串。

13.3 WithTheme 配置

  • ✅ 整个应用根组件用 WithTheme 包裹。

  • ✅ 在根组件 aboutToAppear() 里调 ThemeEngine.init()

  • BrandTheme.DefaultbuildCustomTheme() 里返回 undefined,保留系统原色。

  • ✅ 亮度切换时调 ThemeEngine.setBrightness(brightness, context),必须传 context。

13.4 BottomTabBarStyle

  • BottomTabBarStyle.of() 不读 WithTheme,必须显式传 labelStyle.selectedColoriconStyle.selectedColor

  • ✅ 不要用强制重挂载 HdsTabs 来更新底栏色——太破坏性。


十四、常见错误对照表

错误做法

问题

正确做法

if (brand === 'pink') 写在每个页面

加品牌改所有页面

ThemeBrandAccent() 语义令牌

声明了 @StorageProp 但 Builder 不更新

@Builder 不跟踪模块级函数

实例方法先读 this.activeBrand

HdsTabs 选中色不变

BottomTabBarStyle 不读 WithTheme

labelStyle.selectedColor 显式传

setBrightness 不传 context

$r() 资源色不切换

getHostContext()

Default 品牌返回 CustomTheme

覆盖了系统原色

Default 返回 undefined

用裸字符串 'arkuilab_active_brand'

容易拼写错误

BRAND_STORAGE_KEY 常量

品牌令牌用在结构 UI(页面背景)

品牌和结构耦合

结构 UI 用结构令牌

强制重挂载 HdsTabs

NavPathStack 重放

labelStyle / iconStyle API

品牌色只有 Light 值

深色模式下品牌色刺眼

每个品牌色都提供 Light + Dark

直接用 $r('sys.color.primary')

不可换品牌

ThemeBrandAccent() 包装层


十五、审计命令

E012 沉淀了三条审计命令:

# 统计品牌令牌使用
rg "ThemeBrandAccent|ThemeBrandButtonBackground|ThemeBrandGradient" entry/src/main/ets -g "*.ets" -l

# 找用了品牌令牌但缺 @StorageProp 的组件
rg "ThemeBrandAccent|ThemeBrandGradient" entry/src/main/ets -g "*.ets" -l \
  | xargs -I{} sh -c 'rg "BRAND_STORAGE_KEY" {} > /dev/null || echo "MISSING @StorageProp: {}"'

# 统计所有 ThemeToken 使用频率
rg "Theme[A-Z][a-zA-Z]+\(\)" entry/src/main/ets -g "*.ets" -o | sort | uniq -c | sort -nr

建议加入 CI,自动检测缺 @StorageProp 的品牌令牌消费者。


十六、当前限制与后续方向

E012 的 Brand Theme Engine v1 已经成立,没有已知未解决的问题。仍需未来实验的能力:

待办项

说明

Typography 令牌层

字体族、字体缩放、字重层级

Radius 令牌层

圆角比例

Shadow 令牌层

按亮度的海拔模型,不只是单一颜色资源

Motion 令牌层

时长、曲线、品牌交互动效

API 26 systemMaterial / menuSystemMaterial

等本地 SDK 暴露

自适应状态栏/导航栏内容色

uiAppearance 或窗口 API

CI 审计脚本

自动扫描缺 @StorageProp 的组件

暂不创建

  • ThemeConfiguration —— ThemeEngine 已经覆盖了所有必要状态管理。

  • ThemeControl.setDefaultTheme() 全局调用 —— 根组件用 WithTheme 足够且更明确。


写在最后

品牌换肤的本质不是"给每个品牌写一套颜色",而是建立一个映射层,让页面代码永远不知道当前是什么品牌

E012 的 Theme Engine v1 实现了这个目标:

  • 二维主题:Brand × Brightness 任意组合

  • 双轨传播:WithTheme 自动(HDS 组件)+ AppStorage 手动(自定义组件)

  • 零改动新增品牌:七步流程,不碰任何页面代码

  • 品牌和结构正交:品牌只管 accent/button/gradient,结构永远是系统令牌

但这个过程里踩了两个非常深的坑:

  1. @Builder 响应式陷阱:声明了 @StorageProp 还不够,Builder 体内必须读 this.xxx 才能触发重执行。

  2. BottomTabBarStyle 不继承 WithTheme:HdsTabs 选中色硬编码系统蓝,必须通过 labelStyle/iconStyle 显式传。

这两个坑的共同特点是:编译通过、类型安全,但运行时不更新。它们无法通过构建发现,只能通过真机切换品牌才能暴露。

记住这几条口诀:

品牌只管 accent 色,结构永远系统令牌。 WithTheme 管 HDS,AppStorage 管自定义。 Builder 里品牌不更新,实例方法读 this 才行。 BottomTabBar 不继承 WithTheme,labelStyle 显式传。 亮度切换要 context,setColorMode 不可少。 Default 返回 undefined,保留系统原色好。 新增品牌七步走,页面代码零改动。

本文的实现基于 ArkUILab 项目 E012 实验。如果你正在做一个需要多品牌主题的鸿蒙应用,可以直接复用这套 Brand × Brightness 二维引擎和双轨传播机制作为起点。

品牌换肤看似只是"换个颜色",背后却涉及令牌分层、双轨传播、响应式陷阱、组件 API 边界和亮度系统级控制等多个维度。把这些维度拆清楚,你的品牌主题引擎就不会"加了品牌要改全站"了。

Logo

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

更多推荐