一、先看两个世界的边界在哪

1.1 页面世界:ThemeEngine 的完整能力

ThemeEngine.ets 的核心结构(节选):

export class ThemeEngine {
  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);
    }
  }

  static isDarkResolved(brightness: ThemeBrightness, context?: common.UIAbilityContext): boolean {
    if (brightness === ThemeBrightness.Dark) { return true; }
    if (brightness === ThemeBrightness.Light) { return false; }
    return context?.config.colorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK;
  }

  // Updates AppStorage state and applies the system-level colorMode so that
  // $r('sys.color.*') and $r('app.color.*') resources also respond to the change.
  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) { /* best-effort */ }
    }
  }
}

两个关键设计:明暗有三态语义(System / Light / Dark),System 态要拿着 context 去解析系统当前 colorMode;setBrightness 同时写 AppStorage 和调 setColorMode,后者是为了让 $r('sys.color.*') 资源也跟着切换——页面世界的颜色是系统托管的。

1.2 组件世界:纯函数的硬约束

组件侧的约束来自分层范式:Core 只做几何与物理(零 ArkUI import),Painter 只消费解析好的数据。这套约束的价值已经反复验证——写进 build() 或 Core 里的主题读取,会立刻摧毁可测试性和可移植性

  • 单测跑在 node 环境,没有 AppStorage,也没有 context;

  • 组件复制到另一个项目,那个项目可能根本没有 BRAND_STORAGE_KEY 这个键;

  • 主题系统换存储方案(AppStorage → Environment → 别的),所有组件跟着改。

所以问题被精确化为:明暗信息必须以普通数据的形式、在组件边界上被递进来

二、契约主体:darkSurface 注入

2.1 组件侧:三态枚举 + 一个布尔

以 ThinkingOrbs 为例,ThinkingOrbModels.ets 里的声明:

/** Theme resolution: Auto defers to host-injected darkSurface. */
export enum ThinkingOrbTheme {
  Auto = 0,
  Light = 1,
  Dark = 2
}

注意注释原话:Auto 的解析权在宿主注入的 darkSurface。组件构造参数同时收两个输入:

ThinkingOrb({
  state: this.state,
  orbSize: ThinkingOrbSize.Avatar,
  theme: this.orbTheme,        // Auto | Light | Dark —— 用户显式偏好
  darkSurface: this.dark,      // boolean —— 宿主对「当前表面明暗」的判定
  ...
})

解析发生在 Models 层的纯函数里(如 resolvePreset(state, size, dark)):

  • theme = Light / Dark:显式指定,忽略 darkSurface——组件强行按指定主题画,用于「浅色卡片上放一个深色球」这类设计意图;

  • theme = Auto:跟随 darkSurface

也就是说 Auto 不是「跟随系统」——是「跟随宿主」。组件根本不知道系统是什么颜色,它只知道「我此刻被放在一块什么样的表面上」。

2.2 宿主侧:解析权与来源多样化

Lab Destination 持有 dark 并负责其来源。ThinkingOrbsLab 里它就是一个手动开关:

@State private dark: boolean = true;
...
Toggle({ isOn: this.dark })
  .onChange(() => { this.dark = !this.dark; })

表面上看「只是个开关」,实际上这是接边约定的红利:dark 的来源可以是任何东西——手动 toggle(Lab 调参)、ThemeEngine.isDarkResolved(brightness, context)(跟随应用设置)、onColorModeChange 回调(跟随系统)——组件对此零感知。MetalFx 组件里同样的输入走同样的路:

@Watch('onConfigChanged') @Prop theme: MetalFxTheme = MetalFxTheme.Auto;
@Watch('onConfigChanged') @Prop darkSurface: boolean = true;
...
const dark: boolean = metalFxResolveDark(this.theme, this.darkSurface);

解析结果在跨界之前就被压平成一个布尔(native config 里没有枚举,见《Native 图形组件工程化》第四节)——同一份约定平滑延伸到了 C++ 边界。

2.3 为什么不是 @StorageProp

ArkUI 其实有现成的捷径:@StorageProp('isDark') 直接把 AppStorage 的键链到组件。我们禁用它,三个理由:

  1. 键名耦合@StorageProp 把宿主应用的存储键名硬编码进组件。键一改,组件静默失效(拿不到更新,也不报错)。

  2. 测试不可达:单测环境没有 AppStorage,@StorageProp 组件无法脱离 UI 框架实例化验证。

  3. 语义错位:组件需要的不是「全局明暗」,而是「我所在表面的明暗」。深色应用里完全可以有一块浅色卡片——只有宿主知道组件真正落在哪,全局状态给不了这个答案。

三、契约变体:主题值对象注入

darkSurface: boolean 解决的是二分明暗。当组件的皮肤复杂到「明暗之外还有整套配色」时,契约升级为主题值对象——GrokBot 是这个形态:

// GrokBotModels.ets
static dark(): GrokBotThemeData { ... }

GrokBotThemeData 是一个完整的调色板值对象(描边、填充、高光、表情色……),Models 层提供 dark() 等工厂。宿主不传布尔,直接传拼好的 GrokBotThemeData,组件连「解析」这一步都不做——Painter 拿到什么画什么

两个形态的选型:

darkSurface: boolean

主题值对象

适合

二分明暗 + 组件内置两套配色

皮肤维度多(品牌×明暗×尺寸)

解析位置

组件 Models 层

宿主或工厂

组件复杂度

低(一个布尔)

高(接受整对象)

代表

ThinkingOrbs / BorderBeam / FlowAvatar / MetalFx

GrokBot

共同点不变:数据在边界上递进来,组件不向上伸手

四、与系统 token 的分工:页面用 $r,画笔用注入

还有一条容易混淆的边界:NavigationTitleBarTokens 封装的 $r('sys.color.ohos_id_color_text_primary') 系列令牌,为什么页面能用、Canvas 组件不用?

答案在消费端形态:

  • 声明式属性.fontColor().backgroundColor())接受 Resource 类型,$r 令牌由系统在渲染时解析、随 colorMode 自动切换——页面世界的最优解;

  • Canvas 画笔ctx.fillStyledrawing.Brush.setColor())只吃具体颜色值,没有 Resource 的解析通道。

所以接边约定还有下半句:页面向组件递的不是「资源引用」,而是「已解析的判定」。Lab 页面自己用 $r / ThemeTextPrimary() 摆 UI,同时把 dark 判定递给组件——两条颜色通道在页面层汇合,在组件层互不见面。

五、接边规则清单

把约定收口成六条规则,新组件照抄:

  1. 组件目录零主题 import:不 import ThemeEngine、不 import ThemeTokens、不读写 AppStorage、不用 @StorageProp

  2. 明暗入参双通道theme: Auto|Light|Dark(显式偏好)+ darkSurface: boolean(表面判定);Auto 定义为「听 darkSurface 的」。

  3. 解析进 Models 纯函数resolveXxx(theme, darkSurface) 可直接单测,Light/Dark 显式值覆盖 darkSurface。

  4. 复杂皮肤升级为值对象:多维度调色板用 XxxThemeData 值对象注入,工厂在 Models 层。

  5. 宿主全权负责来源:toggle / 应用设置 / 系统回调都是宿主的事;组件对来源无感知、无假设。

  6. 跨界前压平:进 native 边界前解析成布尔或扁平数字(同 NAPI config 的扁平化纪律)。

结语

「组件不读 AppStorage」这句话,字面上只是一条 import 规则,实质上是一次依赖方向的裁定:主题系统是页面的基础设施,不是组件的。裁定之后,明暗信息像所有其他输入一样,变成构造参数里的一个布尔或一个值对象——它可默认、可覆盖、可单测、可跨界。ArkUILab 的组件能整目录复制进新项目、Core 能在测试框架里跑明暗双套断言、主题系统能独立演进三轮(Foundation → Brand Engine → token 化),都建立在这条接边线上。

分层范式管组件的内部,对外接口范式管组件的外交,这篇的接边约定管它和全局基础设施的关系——三者合起来,才是「纯函数组件」的完整生存指南。

Logo

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

更多推荐