HarmonyOS 鸿蒙 Canvas 组件与主题系统的接边 —— darkSurface 注入约定,让纯函数组件拥有暗色模式
一、先看两个世界的边界在哪
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 的键链到组件。我们禁用它,三个理由:
-
键名耦合:
@StorageProp把宿主应用的存储键名硬编码进组件。键一改,组件静默失效(拿不到更新,也不报错)。 -
测试不可达:单测环境没有 AppStorage,
@StorageProp组件无法脱离 UI 框架实例化验证。 -
语义错位:组件需要的不是「全局明暗」,而是「我所在表面的明暗」。深色应用里完全可以有一块浅色卡片——只有宿主知道组件真正落在哪,全局状态给不了这个答案。
三、契约变体:主题值对象注入
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.fillStyle、drawing.Brush.setColor())只吃具体颜色值,没有 Resource 的解析通道。
所以接边约定还有下半句:页面向组件递的不是「资源引用」,而是「已解析的判定」。Lab 页面自己用 $r / ThemeTextPrimary() 摆 UI,同时把 dark 判定递给组件——两条颜色通道在页面层汇合,在组件层互不见面。
五、接边规则清单
把约定收口成六条规则,新组件照抄:
-
组件目录零主题 import:不 import ThemeEngine、不 import ThemeTokens、不读写 AppStorage、不用
@StorageProp。 -
明暗入参双通道:
theme: Auto|Light|Dark(显式偏好)+darkSurface: boolean(表面判定);Auto定义为「听 darkSurface 的」。 -
解析进 Models 纯函数:
resolveXxx(theme, darkSurface)可直接单测,Light/Dark 显式值覆盖 darkSurface。 -
复杂皮肤升级为值对象:多维度调色板用
XxxThemeData值对象注入,工厂在 Models 层。 -
宿主全权负责来源:toggle / 应用设置 / 系统回调都是宿主的事;组件对来源无感知、无假设。
-
跨界前压平:进 native 边界前解析成布尔或扁平数字(同 NAPI config 的扁平化纪律)。
结语
「组件不读 AppStorage」这句话,字面上只是一条 import 规则,实质上是一次依赖方向的裁定:主题系统是页面的基础设施,不是组件的。裁定之后,明暗信息像所有其他输入一样,变成构造参数里的一个布尔或一个值对象——它可默认、可覆盖、可单测、可跨界。ArkUILab 的组件能整目录复制进新项目、Core 能在测试框架里跑明暗双套断言、主题系统能独立演进三轮(Foundation → Brand Engine → token 化),都建立在这条接边线上。
分层范式管组件的内部,对外接口范式管组件的外交,这篇的接边约定管它和全局基础设施的关系——三者合起来,才是「纯函数组件」的完整生存指南。
更多推荐



所有评论(0)