资讯动态

鸿蒙ArkUI主题系统设计:从Token体系到运行时换肤的完整实践

发布时间:2026/10/9 6:30:14 来源:尧图企业网站定制
我记得很清楚系列前七讲把脚手架、路由、状态管理都聊完之后评论区有人问我你的组件库里颜色写死了下次产品要换品牌色你打算改几个文件这个问题扎心了。上一个React项目里换主题靠CSS变量一行搞定到了HarmonyOS上我竟然要全局搜索某个蓝色再逐个替换还总怕漏掉某处弹窗或占位图。所以这一讲我会把主题系统从设计到落地拆开讲透。如果你也在做鸿蒙应用迁移或者想搭建一套能支撑多套皮肤的组件库这篇内容可以直接当参照物。落地到鸿蒙上的第一件事你要先接受一个现实React Web项目里靠CSS变量一行换肤的爽快感在ArkUI里基本复刻不出来。ArkUI的样式对象是组件属性级别的没有浏览器CSS那种全局继承和级联规则。所以主题系统必须在数据层想清楚而不是在样式层硬适配。这一讲的实现思路就是用类React的Context加上Token体系把主题变成一份受控的全局数据让任何组件在任何时刻都能拿到正确的一套值。这也是我处理鸿蒙React混合工程时衡量一个主题方案是否合格的标尺。1. 主题系统到底要解决什么问题1.1 从改配置文件到运行时整体换肤很多人把主题系统理解成准备两套颜色深浅色各一套然后切换。这个理解没错但如果只做到这一步后面一定会被需求打脸。真实场景里主题至少要解决三个层面的问题全局作用域。主题值定义在一处所有页面、弹窗、组件都能拿到不需要层层传参。运行时切换。用户在设置页点一下深色整个界面马上变化不重启、不清栈。可持久化。重启App后仍然是用户上次选择的主题而不是默默回到默认浅色。三个诉求里第一个决定了工程结构第二个决定了数据流设计第三个决定了存储方案。缺一个换肤就只能算演示Demo不能叫主题系统。我在实际迁移中遇到的典型反例是组件A和组件B各自维护一个isDark布尔值结果切换主题时总有一个页面不听话或者把主题状态放进了页面路由参数里导致跨Tab切换时主题失忆。这些都是因为一开始没有把主题当作全局基础设施来设计。主题系统不是某个页面的功能它是和路由、状态管理同一层级的基础能力。1.2 别把主题和换肤划等号在我的实践里主题系统的本质是一组受控的全局设计变量。深色模式只是其中一个消费者后续还会有品牌色换肤、节日皮肤、高对比度无障碍模式等。所以设计一开始就要把主题抽象成一份可注册的配置而不是写死在代码里的两套对象。一个很典型的例子电商大促时运营要求把主色调换成大红色。如果主题系统只支持明暗切换这个需求就要在业务层写几十个条件判断isSale ? #FF4D4F : theme.colors.primary。但如果一开始就把主题设计成Token体系这种需求只是多注册一套主题变体的事。这个思路就是全篇的骨架先把数据模型设计好后面的功能都是水到渠成。2. 先把主题变量体系搭起来Token三层结构与命名规范2.1 为什么不能直接在组件里写颜色我接手过不少鸿蒙工程最常见的换肤困难户就是直接从设计稿复制颜色值#FFFFFF、#1A1A1A散落在各个组件里。初期没问题等主题需求来了就变成全局搜索加肉眼比对的体力活改完还要提心吊胆地担心漏了哪个。解决思路是引入设计令牌Design Token。Token不是简单的颜色变量它把设计系统里的可复用属性——颜色、字号、间距、圆角、阴影——全部抽成有语义的命名值。这样组件的样式表达式里永远不是#2E6BE6而是theme.colors.primary。好处是显而易见的业务组件不关心具体色值只关心语义主题怎么换都影响不到它们。这里我要特别强调一点Token不是把颜色抽出来存到一个文件就完事了命名规则和层级划分才是核心。没有层级的Token和散落的颜色常量本质上没有区别只是换了个地方写死而已。2.2 基础Token、语义Token和组件Token实践中我会把Token分三层Token层级命名示例作用变更频率基础Tokencolor.primary.500调色盘原始值对应设计稿色板季度级语义Tokencolor.bg.page页面背景、文字、边框等业务语义随主题切换组件Tokenbutton.primary.bg某个组件在某个状态下的具体样式版本级为什么分这么多层我解释一下基础Token是调色盘比如primary.500对应品牌蓝。它不随主题变深色模式下品牌蓝依然是品牌蓝只是背景、文字的用法变了。语义Token是含义到颜色的映射比如页面背景在浅色主题下是white在深色主题下是#111。业务代码只认语义不认具体值。组件Token是语义到组件属性的落地它让按钮、卡片这些组件在主题切换时能统一调整而不需要业务侧逐个写样式。这样设计最大的好处新主题接入时我只需新增一套语义Token和组件Token基础Token基本不动业务代码零改动。主题系统能不能优雅地扩展关键就看这一层结构有没有立住。2.3 鸿蒙工程里主题文件怎么组织我会在工程里建一个独立的theme目录结构大致如下src/ theme/ tokens.ts # 两套主题的Token定义 types.ts # ThemeName、ThemeTokens等类型 ThemeProvider.tsx # 主题上下文与Provider useTheme.ts # 消费钩子 storage.ts # 本地持久化 withSystem.ts # 跟随系统深浅色所有主题相关逻辑收敛到这一个目录业务层只认识useTheme()不直接import颜色常量。这样后续加主题不会污染业务代码。我在迁移时踩过一个教训一开始主题工具函数散落在utils目录里结果谁都能import命名越来越乱最后花了半天才理清楚。主题系统的工程边界一定要从一开始就划清楚。3. 主题状态管理和切换的数据流设计3.1 状态只存主题名不存主题对象很多人实现主题切换时喜欢把整个主题对象放进状态里// 反面示例 const [theme, setTheme] useState(darkTheme);这个做法的隐患在于主题对象是可变数据容易被某处代码不小心改动另外每次切换都要深拷贝否则状态更新可能不触发重渲染。我的做法是状态只存主题名type ThemeName light | dark; const [themeName, setThemeName] useStateThemeName(light); const theme themes[themeName];themes是一个静态配置表不是状态的一部分。组件消费时通过themes[themeName]取到当前主题对象。由于配置表是常量theme的引用在切换前后是否变化完全可控这为后续性能优化打下了基础。这个细节看着不起眼但它决定了整个数据流的稳定性。如果状态里存的是主题对象你就永远要提防哪段代码改了某个属性。存主题名数据源就永远是只读的配置表心智负担小很多。3.2 Context的value必须套useMemo使用React Context承载主题时有个经典坑如果直接写ThemeContext.Provider value{{ themeName, theme, setThemeName }}Provider每次渲染都会生成一个新的value对象就算主题没变所有消费了Context的子组件也会跟着重渲染。这一点React开发者基本都踩过鸿蒙上同样适用。正确做法是const value useMemo( () ({ themeName, theme, toggleTheme, setThemeName }), [themeName] );3.3 首次启动时用户选择优先其次跟随系统主题切换还得考虑系统深浅色联动。HarmonyOS上可以通过configuration.colorMode读取系统当前的颜色模式。我的启动流程是从本地读取用户上次的主题选择。如果有显式选择直接应用该主题。如果没有跟随系统颜色模式。运行时在设置页提供浅色/深色/跟随系统三档。这里要注意持久化和跟随系统是两个状态维度。如果只存一个布尔值表示是否深色一旦系统切了深浅色用户显式选择就丢了。所以存储结构至少要包含主题模式类型、主题名称、版本号。我后面会再说为什么要有版本号。3.4 监听系统颜色模式变化运行时如果系统从浅色切到深色已经被用户设为跟随系统的App需要实时响应。HarmonyOS的能力封装在Configuration里监听方式类似export function watchSystemColorMode(onChange: (mode: light | dark) void): void { // 注册Configuration更新监听 // 解析configuration.colorMode 0 ? light : dark // 回调onChange }回调里只需要更新themeName状态剩下的刷新工作交给ThemeProvider。这一层抽象让我在测试时也能直接mock系统模式非常方便。实战中我发现很多人喜欢在页面里直接写系统颜色模式的判断逻辑结果页面一多判断逻辑散落得到处都是。把所有系统相关逻辑收敛到withSystem.ts这一个文件里维护成本会低很多。4. 核心实现从ThemeProvider到业务组件消费4.1 先定义类型和两套Token前面理论铺了这么多现在写代码。第一步是定义类型export type ThemeName light | dark; export interface ThemeTokens { colors: { primary: string; pageBackground: string; cardBackground: string; textPrimary: string; textSecondary: string; border: string; }; spacing: { xs: number; sm: number; md: number; lg: number; xl: number; }; radii: { sm: number; md: number; lg: number; }; }然后定义两套主题配置export const themes: RecordThemeName, ThemeTokens { light: { colors: { primary: #2E6BE6, pageBackground: #F5F7FA, cardBackground: #FFFFFF, textPrimary: #1A1A1A, textSecondary: #666666, border: #E8E8E8 }, spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 }, radii: { sm: 4, md: 8, lg: 16 } }, dark: { colors: { primary: #3E7BFA, pageBackground: #0D0D0F, cardBackground: #1C1C1E, textPrimary: #F2F2F2, textSecondary: #A6A6A6, border: #2C2C2E }, spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 }, radii: { sm: 4, md: 8, lg: 16 } } };注意深色主题里的primary我没有用和浅色完全一样的色值而是提亮了一档。这是因为深色背景下深蓝色容易看不清稍微提亮能保证可读性。这种细节在主题系统里非常多不要指望同一个色值在明暗两套背景下都好用。4.2 ThemeProvider骨架import React, { createContext, useCallback, useContext, useMemo, useState } from react; interface ThemeContextValue { themeName: ThemeName; theme: ThemeTokens; toggleTheme: () void; setThemeName: (name: ThemeName) void; } const ThemeContext createContextThemeContextValue | null(null); export const ThemeProvider ({ children, initialThemeName light }: { children: React.ReactNode; initialThemeName?: ThemeName; }) { const [themeName, setThemeName] useStateThemeName(initialThemeName); const toggleTheme useCallback(() { setThemeName((name) (name light ? dark : light)); }, []); const value useMemo( () ({ themeName, theme: themes[themeName], toggleTheme, setThemeName }), [themeName, toggleTheme] ); return ( ThemeContext.Provider value{value} {children} /ThemeContext.Provider ); };toggleTheme用useCallback包一层value的依赖数组里包含它这样整个value的引用变化完全由themeName驱动。如果你发现某个组件在主题不变时莫名其妙的刷新先看这里是不是没包好。4.3 useTheme钩子export function useTheme(): ThemeContextValue { const ctx useContext(ThemeContext); if (!ctx) { throw new Error(useTheme必须在ThemeProvider内部使用); } return ctx; }这里要专门解释抛错的意义如果不抛业务组件误用时会拿到空对象样式全部变灰色报错信息是undefined相关的排查起来非常费劲。主动抛错可以让问题在开发期就暴露在控制台上。另外如果未来要做局部主题覆盖这个Hook还能扩展参数做场景化处理而现在这种写法留出了扩展口。4.4 业务组件消费主题的两种姿势业务组件有两种常见的消费方式。第一种用useTheme拿全局主题用useMemo生成样式对象const HomePage () { const { theme } useTheme(); const styles useMemo( () ({ container: { flex: 1, backgroundColor: theme.colors.pageBackground }, title: { color: theme.colors.textPrimary, fontSize: 18 } }), [theme] ); return ( View style{styles.container} Text style{styles.title}首页/Text /View ); };第二种容器组件只把主题色作为props传给叶子组件。这种适合弹窗、列表项这类细粒度组件减少它们对Context的依赖const ListItem ({ text, textColor }: { text: string; textColor: string }) { return Text style{{ color: textColor }}{text}/Text; };怎么选我个人的原则是页面级组件用第一种因为页面本身就要响应主题变化频繁复用的小组件尽量用第二种通过props传颜色让它在主题切换时呈被动更新避免无意义的Context订阅。4.5 组件级memo怎么配合主题数据流搭好了还要防住过度渲染。主题切换时themeName变了theme引用变了所有调用useTheme的组件都会重新渲染一次。这是合理的因为它们确实依赖主题。但要防止的是某个组件本身没有消费主题只是因为父组件刷新而被带着刷新。解法就是通过React.memo包裹让props没变的组件跳过export const PureBadge React.memo(Badge);同时在父组件里给PureBadge传入的参数也要保持引用稳定不要再每次渲染时内联创建新对象。这两点配合起来主题切换的渲染范围就能控制在真正依赖主题的组件上了。5. 实测中的坑ArkUI渲染管线和React数据流的差异5.1 大列表页面的雪崩式刷新问题主题Context是全App共享的切换时要刷新所有页面。如果某个页面是几千行的长列表每条item都消费了主题那切换瞬间就会卡顿。我第一次在鸿蒙模拟器上跑通深色切换时列表页面肉眼可见地顿了一下大概有300ms的掉帧。后来改成两层方案把主题名和主题对象拆成两个Context。需要响应切换的组件订阅Name只需要当前主题值的组件订阅对象。列表item尽量走props传色而不是都调useTheme。这样大列表里的item只在数据变化时更新不随着主题切换整体重排。这个思路和React 18的细粒度更新是一脉相承的在实际工程里很有效。如果你实测切换掉帧先检查列表页的item是不是人人都在调useTheme这通常就是瓶颈。5.2 静态资源不换肤颜色可以换肤图标和图片不一定。这是主题系统里最容易漏的地方。很多组件里写了Image source{require(./assets/logo.png)} /深色模式下白底logo放在深色背景上四边都是白框丑得不行。我的处理方法有三种按优先级排列用字体图标或SVG替代位图。颜色可以传给Symbol随主题变化。同一个资源准备两套主题配置里映射不同路径。给位图套一层混合或遮罩用主题色重新着色。大多数场景下第一种是彻底的解法。设计侧同步调整后组件里就不再出现某张图只适配某主题的硬编码了。鸿蒙的资源目录支持同名多限定符但如果你在React层做跨端复用还是优先考虑字体图标方案。5.3 深色模式下的阴影和毛玻璃深色模式不是简单地把背景变黑。我在实测中发现最常见的三个问题阴影浅色模式下阴影能给卡片浮起来的层次感深色模式下背景本身是黑的阴影自然看不见卡片区域一片糊。解决方案是改用描边或者降低透明度但加大阴影模糊半径。半透明遮罩弹窗背景如果用30%黑色深色模式下遮罩几乎看不出层级需要根据主题切换遮罩颜色。毛玻璃鸿蒙支持毛玻璃效果但深色模式下如果blur处理不好很容易出现紫色或灰色的色带需要谨慎设置背景饱和度。这类问题不光是React迁移会遇到原生鸿蒙开发同样躲不开。主题系统只不过把这些边界情况集中暴露出来了所以设计Token时要专门加一项阴影相关配置让阴影在浅色和深色下用不同方案。5.4 转场动画里的主题闪烁还有一个很隐蔽的坑页面转场动画进行到一半时切换主题部分页面是旧主题另一部分已经是新主题。最典型的出现位置是底部Tab切换和路由转场。我的应对措施是主题切换时不强制中断动画让动画自然结束如果产品要求立即切换则在切换时给根容器一个极短的opacity过渡淡出-换肤-淡入视觉上把闪烁变成渐变。这个方案在React Native和ArkUI上都能复用用户反馈比瞬时切换体感更好。6. 主题系统还能扩展哪些玩法6.1 品牌色/节日主题用Merge而非重写如果某次大促只需要把primary从蓝色变成红色完全不需要重新定义一整套Token。可以先定义主题变体配置const saleTheme: PartialThemeTokens { colors: { primary: #FF4D4F } }; export function applyVariant(base: ThemeTokens, variant: PartialThemeTokens) { return { ...base, colors: { ...base.colors, ...variant.colors } }; }运行时主题注册表里维护了light、dark、light-sale、dark-sale这几个虚拟主题名每个名字对应一个基础主题变体的组合。业务组件无感运营同学却很满意——他们不用等版本发版就能换配色方案。6.2 高对比度模式当作一个普通主题无障碍的高对比度模式别放在页面里做一堆if (isHighContrast)分支直接把它做成主题系统里的第三个ThemeName。对比度要求上通用经验是正文文本和背景的对比度不低于4.5:1大号文本也要3:1以上。把这些要求落到Token上组件代码不用为无障碍写任何特例。这个设计对测试也很友好你只需要在主题注册表里加一个highContrast主题然后让测试脚本遍历所有主题名跑一遍页面就能发现哪些组件在高对比度下样式异常而不需要专门维护一套无障碍测试逻辑。6.3 给主题存储加版本号回到前面埋的伏笔。第一个版本的主题存储结构我只写了一个字段{ themeName: dark }后来Token结构调整老用户缓存里没有新字段切换时直接取不到值。我的补救措施是在存储里加version{ version: 2, themeName: dark }读取时先检查版本号不兼容就重置为默认主题。这件事花了我一个晚上的排查时间写出来就是希望你不用再踩一遍。别小看这个字段主题系统活得越久Token结构越可能进化版本号就是你和历史数据之间的安全绳。主题系统做扎实之后最大的收益其实不是换肤本身而是整个组件的样式表达都收敛到了一套有语义的变量上。后面不管设计稿怎么改业务组件都不需要动。最后分享一个我在实操中的体验不要一上来就写Provider和Hook先把Token结构对着设计稿梳理一遍确认页面背景、卡片背景、文字主次色、边框、分隔线这些语义足够覆盖当前所有页面再动代码。结构没想清楚之前写再多的切换逻辑都是在给未来挖坑。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑