说实话,HarmonyOS应用做到中大型规模之后,“主题系统”这四个字就不再是换几个颜色那么简单了。我在自己的开源教程系列里把React那套状态管理、Context分发、Hook化复用的思维带到HarmonyOS上,做了一套能落地的主题实现,本期就把它完整拆开。无论你是从React Native、前端转过来的,还是一直纯写ArkTS,这套方案的思路都能直接抄作业,代码部分也是基于HarmonyOS NEXT SDK(API 12+ / 5.0.0(12))整理的。
这篇内容要解决的问题很明确:怎么在ArkUI声明式体系里设计一套可扩展、可持久化、能跟随系统深色模式、又能支持业务自定义换肤的主题系统。适合谁看?正在做应用级UI基础设施的开发者,被“切主题后页面不刷新”“资源文件改不了颜色”“深色模式适配特别乱”折磨过的人,以及想把自己React经验平滑迁移到鸿蒙项目里的同学。
1. 主题系统整体设计:先想清楚再写代码
1.1 为什么主题系统是应用的“基础设施”
很多App从第一天就聊“支持深色模式”,但真正做到后面,要么是每个页面拿isDark到处判断,要么是if (dark) backgroundColor = '#000'写满业务代码。等主题颜色从一套变两套、三套,甚至服务端下发动态换肤时,这套写法会直接把项目拖垮。
主题系统的本质,是把“长什么样”和“怎么画”彻底分离。UI组件只消费一组语义化变量,就像一个组件说“我要主背景色”,但它不关心主背景色在深色模式下到底是#FFFFFF还是#0A0A0A。这样业务组件、公用组件、图表库各自独立,换皮肤就像是给屋子换光源,家具不用挪位置。
如果你熟悉前端工程化,这事对标的就是CSS Variables加上Design Tokens。放到HarmonyOS里,我们同样需要一个token池、一个全局分发渠道、一套订阅刷新机制。这就是整套系统的基础架构。
1.2 从React生态借鉴的四个核心思路
我在做这个系统时,其实一直在用React的开发心智。很多HarmonyOS开发者觉得ArkTS是另一套东西,但只要你做过几年React,会发现声明式UI的思路真的一模一样。直接说四个可迁移的点:
第一,单一数据源。React里我们习惯把应用状态收敛到一个顶层Store里,ArkUI里对应的就是AppStorage或者UIAbility级的单例对象。主题状态必须全局只有一份,任何组件都不允许自己去改颜色体系,只能通过统一的changeTheme方法去更新。
第二,Context化分发。React的Context提供了一种“父组件声明,任意层级子组件消费,中间组件不感知”的能力。ArkUI的@Provide和@Consume其实就是这个模式,用起来非常顺手。
第三,Hook化逻辑复用。React 16.8推Hooks之后,状态逻辑从类组件里摘了出来,可以在函数组件间自由组合。ArkTS虽然不能真的写运行时Hook,但我们可以用装饰器加工具函数,封装出useTheme()风格的消费方法,让组件内部代码清爽很多。
第四,不可变更新。React性能优化的关键就是state更新时生成新引用,浅比较就能判定是否重渲染。主题切换也是一样的,不要原地修改token对象的属性,而是整体生成一个新主题对象然后替换,这样@Watch才能稳定触发,UI刷新也不会拖泥带水。
2. 主题令牌体系:把设计稿翻译成数据结构
2.1 主题词典怎么设计
主题系统第一步,不是写代码,是定数据结构。我的做法是把所有视觉变量收敛成一个ThemeTokens对象,英文叫design token,国内很多团队叫“样式词典”。它的职责是翻译设计稿,把“品牌蓝”“背景灰”这种口语化描述,变成工程可用的变量。
最核心的几个维度是:语义颜色、状态颜色、字体、圆角、间距、阴影。不要直接把#FF0000这种原始值散落在组件里,而是先定义语义,比如colorPrimary、colorBgPage、colorBgCard、textPrimary、textSecondary、dividerColor、successColor、warningColor、errorColor等。语义化命名的好处是,组件端根本不需要关心主题是什么风格,它只要知道自己想表达“主要文字”这个意思就够了。
这里有一个很容易踩的坑:不要把“深色值”直接塞进“浅色主题”的同一个字段里。很多初学者会搞一个if (isDark) return '#111' else return '#FFF',这样写死的逻辑没法支撑多主题扩展。正确做法是每一套主题都完完整整提供同一结构的所有字段,多主题只是多份对象实体的实例。
2.2 颜色、字体、圆角、间距的取值规范
在设计ThemeTokens时,需要建立一套取值规范。颜色我建议统一使用十六进制ARGB字符串,因为ArkUI的Color和ResourceColor都接受这种格式,序列化传输也方便。另外要额外存一份预转换好的数值化颜色,供Canvas动效和图表库使用,避免每次渲染都去做字符串解析。
圆角、间距、字号这类数值型token,建议以设计稿的基准单位为基础。比如间距基础单位是4vp,那么spaceXS = 4、spaceSM = 8、spaceMD = 16、spaceLG = 24、spaceXL = 32。字号沿着字体梯度走,标题和正文明确分开。不要小看这个设计,后面做深色模式时,对比度不足多半是因为颜色取了中间灰,而不是纯白配纯黑,所以规范里要写清楚浅色和深色模式下文字与背景的最低对比要求。
阴影在深色模式下也有讲究。浅色模式阴影可以用黑色半透明,深色模式再叠加一层黑色阴影就看不清了,应该改为用高透明度的白色或对背景进行视觉加深替代。这些细节如果不前置进入token体系,后面全是硬编码补丁。
2.3 HarmonyOS资源与运行态数据怎么配合
HarmonyOS本身有一套resource资源机制,支持 base 和 dark 两个目录,系统切深色模式时资源文件自动切换。很多教程会让你把颜色写进resources/base/element/color.json,然后组件里用$r('app.color.xxx')引用。这套方案对跟随系统的深色适配很管用,因为系统切换时资源系统会自动重新解析。
但这里有个边界问题:如果用户想在应用内部手工切换主题,或者服务端下发一个自定义主题包,纯资源文件就力不从心了。因为$r()引用的是编译期资源,运行时没法动态改它的值。我当时做过一次对比实验,最后采用的是“双轨制”:
- 系统能力、平台默认样式、国际化相关颜色,走resource;
- 业务主题、品牌色、动态换肤变量,走运行态token对象,通过全局状态管理下发。
这样既享受了系统的自动能力,又保留了运营灵活性。模块的边界不要混,否则排查问题时你会不知道颜色到底是资源控制的还是代码控制的。
3. 核心实现:全局状态管理与主题分发
3.1 用AppStorage + @StorageProp做全局主题
HarmonyOS的AppStorage是应用级的全局状态单例,非常适合放主题这种跨页面、跨组件共享的数据。它本身还支持与UI组件状态双向同步,数据变化会驱动依赖它的组件刷新。
我定义了一组主题实体类,这里直接用ArkTS写出来看:
// model/AppTheme.ets export class ThemeTokens { colorPrimary: string = '#1677FF'; colorPrimaryActive: string = '#0958D9'; colorPrimaryBg: string = '#E0F2FF'; colorBgPage: string = '#F5F7FA'; colorBgCard: string = '#FFFFFF'; colorTextPrimary: string = '#1F2329'; colorTextSecondary: string = '#646A73'; colorDivider: string = '#E5E6EB'; colorSuccess: string = '#00B42A'; colorWarning: string = '#FF7D00'; colorError: string = '#F53F3F'; radiusSM: number = 4; radiusMD: number = 8; radiusLG: number = 12; spaceXS: number = 4; spaceSM: number = 8; spaceMD: number = 16; spaceLG: number = 24; spaceXL: number = 32; fontSizeCaption: number = 12; fontSizeBody: number = 14; fontSizeTitle: number = 16; fontSizeLarge: number = 20; } export class AppTheme { name: string = 'default'; isDark: boolean = false; tokens: ThemeTokens = new ThemeTokens(); }在外层入口创建默认主题对象并注册进AppStorage:
// entry Ability 的 onCreate 或者入口页面中 AppStorage.setOrCreate('appTheme', new AppTheme());在组件里消费时,用@StorageProp绑定这个全局key。建议同时加一个@Watch监听变化,这样切主题后你可以在这里做一些非UI的副作用响应:
@StorageProp('appTheme') @Watch('onThemeChanged') appTheme: AppTheme = new AppTheme(); onThemeChanged() { // 可以做埋点、日志等副作用 }需要注意,@StorageProp('appTheme')绑定的是对象引用。我们在2.x里强调过不可变更新,切换主题时必须生成新的AppTheme对象整体替换,而不是改this.appTheme.tokens.colorPrimary = '#xxx'。原因很简单:对象属性级变更时,ArkUI的@Watch不一定能稳定感知每一层的属性变化,整体替换一次引用,所有消费方都能得到明确的刷新通知,这也是React里“用setState创建新对象”的核心思想。
3.2 用@Provide/@Consume模拟React Context
@StorageProp适合直连AppStorage的场景,但有时候我们只希望某个页面子树消费主题,其他模块不参与,或者希望避免全局key被误写。此时更好的选择是用@Provide/@Consume,这一对装饰器就是ArkUI版的Context。
在根容器组件里提供主题:
@StorageProp('appTheme') appTheme: AppTheme = new AppTheme(); @Provide('theme') theme: AppTheme = this.appTheme;在任意深度的子组件里消费:
@Consume('theme') theme: AppTheme;这样从根节点到子孙节点的传递路径上,中间的组件完全不需要一层层传参。这和React Context的Provider/Consumer体验几乎一模一样,页面组件可以把主题能力在局部范围内隔离,避免整个应用都去监听AppStorage造成无关刷新。
使用@Consume时我给一个建议:封装成一个业务基类或者公共组件,不要让每个页面里直接写裸的@Consume('theme'),因为一旦key拼写错了,是编译期查不出问题、运行期绑定为空的麻烦。把消费逻辑封装起来,统一从ThemeEngine获取,能少很多隐性Bug。
3.3 封装useTheme工具函数,复用逻辑
React开发者的一个习惯是封装自定义Hook,把“状态加操作”打包给组件用。ArkTS虽然没有Hook运行时概念,但我们可以照葫芦画瓢,做一个useTheme工具,内部封装AppStorage读写。
// utils/useTheme.ets export class UseThemeResult { theme: AppTheme = new AppTheme(); switchTheme(name: string): void { ThemeEngine.changeTheme(name); } } export function useTheme(): UseThemeResult { const theme: AppTheme = AppStorage.get<AppTheme>('appTheme') ?? new AppTheme(); const result = new UseThemeResult(); result.theme = theme; return result; }组件里这样用:
private themeResult: UseThemeResult = useTheme(); build() { Column() { Text('Hello Theme').fontColor(this.themeResult.theme.tokens.colorTextPrimary) } }当然这里面有个绑定问题:函数式返回的对象不会自动参与UI状态追踪,所以在实际项目中我会在页面里同时保留@StorageProp装饰的成员变量,再用useTheme这种风格包装操作逻辑。写出来的代码既有React hook的直觉,又符合ArkUI状态系统的规则。
4. 动态切换、系统模式监听与持久化
4.1 用户手动切换主题的完整链路
我们来完整走一遍用户点击“切换深色模式”后,主题系统内部发生了什么。先定义一个ThemeEngine,它是整个主题系统唯一的操作入口:
// services/ThemeEngine.ets export class ThemeEngine { static readonly THEME_KEY = 'appTheme'; static readonly CURRENT_THEME_NAME = 'current_theme_name'; static getCurrentTheme(): AppTheme { return AppStorage.get<AppTheme>(this.THEME_KEY) ?? new AppTheme(); } static changeTheme(name: string, dark: boolean) { // 实际项目中这里可以根据 name 走内置主题工厂或服务端主题中心 const theme = ThemeFactory.createTheme(name, dark); AppStorage.setOrCreate<AppTheme>(this.THEME_KEY, theme); this.persistThemeName(name); } private static persistThemeName(name: string) { // 这里对接Preferences持久化逻辑 } }完整链路有四步:
- 用户点击UI按钮,调用
ThemeEngine.changeTheme('midnight', true); ThemeFactory根据名称和模式,生成一个全新的AppTheme对象;AppStorage.setOrCreate('appTheme', theme)触发所有@StorageProp、@Provide/@Consume依赖方更新;- 受影响的组件自动调用
build,重新从token里取最新颜色画到界面。
这个链路少了一个状态,那就是用户打开的“跟随系统”开关。如果用户选择了跟随系统,手动的模式切换按钮就不能只改主题,而要把这个偏好存起来,下一次监听系统配置变化时再自动切换。所以我们把主题状态拆成 “用户主动选择主题名 + 是否跟随系统” 两个维度,切换时要么改主题包,要么改跟随开关。
4.2 跟随系统深色模式的监听与联动
HarmonyOS应用在UIAbility里可以感知系统配置变化。我在EntryAbility中通过onConfigurationUpdate监听系统colorMode,拿到最新的系统深色状态后,联动更新业务主题包:
// EntryAbility.ets onConfigurationUpdate(newConfig: Configuration) { const isDark = newConfig.colorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK; ThemeEngine.syncWithSystem(isDark); }ThemeEngine.syncWithSystem内部会判断用户当前是否开启了“跟随系统”,如果开启了,就重新根据系统模式生成主题并写入AppStorage;如果用户关闭了跟随系统,则保持手动主题不变化。这个机制对应到React生态里,就是“应用配置 + 系统外部事件”驱动状态,状态再驱动UI,外层页面的业务组件完全不感知。
这个联动方案有几个实现细节要注意。一是AppStorage里要额外存一个followSystem布尔值;二是在Ability初始化时就要读取一次系统状态,避免冷启动期间出现“先浅色闪一下再变深色”的问题;三是如果你的应用是多窗口或者有卡片,要留意配置更新回调可能来自不同窗口,主题写入时要保证是全局key,而不是某个页面的局部状态。
4.3 用户偏好持久化
用户手动选择了主题,重启App以后应该还是这个主题,否则体验会很割裂。HarmonyOS上我建议用@ohos.data.preferences来做轻量存储,它本质是一个本地的键值对库,适合存这种配置型数据。
我封装了一个ThemeStorage来管理偏好:
// services/ThemeStorage.ets import { preferences } from '@kit.ArkData'; import { common } from '@kit.AbilityKit'; export class ThemeStorage { private pref: preferences.Preferences | undefined = undefined; async init(context: common.UIAbilityContext) { this.pref = await preferences.getPreferences(context, 'themeStore'); } async saveThemeName(name: string) { if (!this.pref) return; await this.pref.put('themeName', name); await this.pref.flush(); } async saveFollowSystem(follow: boolean) { if (!this.pref) return; await this.pref.put('followSystem', follow); await this.pref.flush(); } async loadThemeConfig(): Promise<{ themeName: string, followSystem: boolean }> { if (!this.pref) { return { themeName: 'default', followSystem: true }; } const themeName = await this.pref.get('themeName', 'default') as string; const followSystem = await this.pref.get('followSystem', true) as boolean; return { themeName, followSystem }; } }App启动时,在UIAbility的onCreate里初始化这个存储,读取配置后重建主题对象。我实际测下来,整个读取重建过程在毫秒级,不会明显拖慢首帧,但要注意不要在拿到Preferences之前去渲染依赖主题的页面,否则会有一段默认主题闪屏。解决办法是启动时先展示一个纯色启动页,等主题配置就绪后再进入首页。
5. 组件消费主题与局部覆盖
5.1 基础组件如何消费主题变量
当主题系统落地到组件层之后,最重要的原则就是:组件直接用语义token,不要自己发明颜色。一个Button组件,它的背景色、文字色、按压态颜色都应该来自theme.tokens,而不是硬编码某个品牌蓝。
@Component export struct ThemeButton { @Consume('theme') theme: AppTheme; build() { Button('确定') .backgroundColor(this.theme.tokens.colorPrimary) .fontColor(this.theme.tokens.colorBgCard) .borderRadius(this.theme.tokens.radiusMD) } }这样改造后出现一个现象:很多原本用颜色堆砌的组件代码急剧变瘦。你不再需要在每个组件里写一组颜色常量,只需要在顶部声明@Consume('theme'),然后按语义去取。设计侧后续调整品牌色,只要重新生成主题包,全应用的按钮、卡片、导航栏全部自动更新,这也就是主题系统的核心价值:一次改动,全局生效。
5.2 局部覆盖:组件要不要给主题留出口
不是所有组件都应该无条件跟随全局主题。有些组件在特定业务场景里就该特殊化,比如营销页里的“限时抢购”按钮,必须用营销运营给的红色,不能跟随普通主题。所以主题系统还要提供局部覆盖机制。
在React生态里,这相当于在theme基础上给组件加overrideprops。ArkTS这边我推荐的做法是:组件的公开API里增加一个主题扩展对象themeExt,用“全局token为主,局部覆盖为辅”的方式合并:
@Component export struct MarketingButton { @Consume('theme') theme: AppTheme; @Prop themeExt: ThemeTokens | null = null; private get mergedTokens(): ThemeTokens { if (this.themeExt != null) { // 实际项目里这里应该做深合并,而不是浅替换 return this.themeExt; } return this.theme.tokens; } build() { Button('限时抢购') .backgroundColor(this.mergedTokens.colorPrimary) } }注意,局部覆盖不能滥用。如果业务里大量组件都引入自己的主题覆盖,说明设计系统的语义化维度不够,该往token池里补新的语义字段了,而不是在组件层到处打补丁。
5.3 图表等高复杂度场景的适配
主题系统还要处理一个容易被忽略的问题:图表。很多项目用的图表库如果直接硬编码了颜色,切深色模式后图表会变成一片刺眼的亮色块。以K线图为例,行情App通常意味着趋势、涨跌、均线量大,颜色语义非常敏感。在主题系统里,我给图表层单独设计了一套图表token,包括蜡烛上涨色、下跌色、均线色、网格线色、十字光标色。
export class ChartThemeTokens { kUpColor: string = '#F53F3F'; kDownColor: string = '#00B42A'; ma5Color: string = '#F7BA1E'; ma10Color: string = '#1677FF'; ma20Color: string = '#722ED1'; gridLineColor: string = '#E5E6EB'; crossLineColor: string = '#1F2329'; }引到图表配置项时,直接拿token去填。这样K线图不仅会跟随浅色/深色自动切换,还能配合品牌换肤。再看React图表生态,像uPlot这类轻量库之所以受欢迎,就是因为颜色、网格都是可配置项,完全可以通过你的theme对象驱动。我的建议是:图表初始化时不要缓存颜色常量,每次重绘前都从ThemeEngine.currentTheme读取一次最新token,代价小,效果自然。
6. 常见问题与排查实录
6.1 切换主题后页面不刷新
这是所有主题系统里遇到最多的一个问题,症状是:调用changeTheme后日志里看到AppStorage值已经更新了,但UI没有变化。最容易出现的原因有三个。
第一,修改了对象的嵌套属性而不是整体替换对象。ArkUI状态系统对“对象属性级变更”的感知策略在不同API版本里表现不一样,如果只改了theme.tokens.colorPrimary,部分场景下UI不会稳定响应。解决办法就是我们在4.1里强调的,永远创建新的AppTheme对象。
第二,在非UI组件层级里消费了主题,比如一个普通的工具类里从AppStorage读了主题,但在组件里没有用@StorageProp或@Consume做绑定,那数据当然不会驱动UI。排查时先确认组件里有没有状态装饰器绑定全局key。
第三,页面用了@StorageLink而外部用了setOrCreate写同名对象,偶尔会出现绑定同步不如预期。我的经验是全局主题统一用@StorageProp(单向)而不是@StorageLink(双向),因为主题系统本来就是顶层写、所有组件读,不需要子组件反向改全局主题。
6.2 资源文件不能动态切换的边界
前面说过,$r('app.color.xxx')引用资源文件时,系统深色模式会自动切换base和dark目录,但用户手动换主题包时资源方案不会生效。很多同学一开始用了resource方案,后来要上自定义换肤就翻车了。
我给的排查建议是:先理清楚每个颜色的来源。全应用搜一遍$r('app.color'),如果是系统级通用色(比如系统分割线、默认文字色)可以保留resource;如果是品牌色、主题色,必须改成token取值。如果项目已经大量使用了resource颜色,又想叠加自定义主题,可以做一个“颜色透传层”:对外保留组件接口,内部把重要颜色改为运行态读取。
6.3 颜色闪烁与过度绘制
切主题时偶尔能明显看到“整页闪白”,这通常是页面根容器背景色仍然硬编码了白色,或者主题切换后子组件重绘顺序不一致导致的。建议根容器也消费theme.tokens.colorBgPage,并且切换主题时不要在子组件里做逐层动画,ArkUI的重绘效率足够,你只需要保证数据整体替换,没必要为“渐变色过渡”去额外引入动画,绝大多数场景下突变反而更干净。
过度绘制问题多出在多层容器都设置了不透明度或阴影。深色模式下尤其明显,因为阴影几乎不可见,却还在参与绘制。检查一下跟主题无关的阴影和半透明层,该条件卸载就条件卸载。
6.4 卡片等受限场景的主题问题
如果你在开发HarmonyOS的卡片(Form),要提前有个认知:卡片的渲染环境和主应用窗口是隔离的,AppStorage的全局主题并不直接传递到卡片侧。卡片可以读取Preferences里的主题名,然后重新构造一个轻量token,在卡片JS/ArkTS侧单独应用。卡片不支持运行时动态DownLoad主题包,需要在卡片更新时把主题包内容一并带上去。
6.5 状态管理颗粒度与性能
主题对象如果做得特别巨大,每次切换时全部消费组件都重建,页面复杂时会有掉帧风险。我的做法是分层:应用级组件只消费“页面主题packet”(背景、导航色、卡片色),业务组件消费“组件token”,图表单独消费“图表token”,不要让一个页面拿着整个AppTheme到处传。阿特拉斯式的大对象对可读性和可维护性都是负担。
7. 性能优化与后续扩展
7.1 计算值缓存与最小化重渲染
主题系统的性能优化,核心不是“减少颜色计算”,而是“减少不必要的组件重绘”。每次AppStorage.setOrCreate会触发所有绑定方刷新,如果一个页面的几十个组件都绑定了同一个主题对象,就会引起较大范围的重新布局和绘制。
我的方案是“粗粒度订阅,细粒度消费”:页面级容器统一订阅一次全局主题,再通过@Provide向下分发,子组件只从@Consume读取,不直接挂@StorageProp。这样全局变化只触发页面根组件的watch,子组件跟着走容器刷新机制,渲染框架会做最小化更新。实测复杂首页在切主题时,帧率稳定在60以上。
7.2 按模块拆主题包
后续主题外延会越来越多,如果只做一个大ThemeTokens,所有人都往里加字段,很快就变成杂物间。我比较推荐按模块拆主题包:基础token包、组件token包、业务模块token包、图表token包。基础包尽量稳定,业务包可以跟随运营活动频繁扩展。这样不同团队管不同包,互不污染。
拆包后还会遇到一个好处:可以做按需加载。运营活动主题包不常被用到,建议只下发被活动页引用的业务token包,避免把整包主题打进主bundle里。这里还可以延伸一个玩法:服务端通过SSE或WebSocket长连接推送主题包变更,客户端收到消息后合并到全局主题。这种“远程换肤”能力非常适合电商大促和内容型应用,配合本地缓存能做到秒级生效。
7.3 AI辅助生成主题的思考
聊一个我最近在玩的方向。React生态里有很多工具在探索用LLM生成UI主题,你的HarmonyOS主题系统也可以这样做。因为主题数据已经结构化——每个主题就是一个纯JSON可序列化的对象,天然就是AI可以操作的数据格式。让AI去识别设计稿的主色、辅助色、中性色和文本层级,输出一份符合ThemeTokens结构的主题配置,再通过ThemeEngine动态加载,整个链路非常顺。
不过要注意校验环节。AI生成的颜色经常在对比度上不达标,我建议做一个主题校验器,检查文字与背景对比度是否满足无障碍需求,不合格的直接拦截,并提示用AI重新生成。这也是把主题系统当成工程设施后,自然生长出来的能力。
我在实际项目中,最受益的一点是把主题当成一个纯粹的不可变数据流来看待。React里的状态驱动UI,ArkUI里的全局状态驱动界面,背后的心智模型完全相同。主题系统实现到全自动换肤、远程下发、AI生成这一步,你就会发现,当初多花的那点时间,换来的是后面每个版本改UI都像喝水一样轻松。