HarmonyOS 7 深色模式与全局换肤实战:一套色板管到底
晚上十一点,用户关了灯刷你的应用——刺眼的白底把他晃清醒了。他去找系统设置里的深色模式,开启之后回到你的应用:还是白的。(这不是假设的场景——想想你自己晚上第一次在某个没适配深色的应用里的体验。)这时候用户心里只有一个判断:这应用没做完。
这个判断的分量比多数产品经理以为的重——它不体现在问卷里(没人会因为深色模式打差评),它体现在「夜间时段的留存曲线」里,沉默而真实。深色模式的用户开启率在各家系统的统计里都过了两位数百分比,晚间时段更高——「你的应用在晚上的第一印象」在很大程度上就是「深色适配做得怎么样」。适配完整的应用,用户感知是「它跟系统是一体的」;白屏一片的应用,感知是「它是外来户」。这种归属感的差异不动声色地影响留存,也解释了为什么应用市场把深色适配列进审核关注项——平台在用审核推着生态对齐系统级体验。深色模式早就从「加分项」变成了「完成度的一部分」:系统层面一键全局深色,用户默认每个应用都会跟上;应用市场上架审核也把深色适配列入了检查项。为什么这两件事要放在一起讲?因为它们共享同一个骨架:都是「全局视觉状态的切换」,深色模式是双值维度的切换(深/浅),品牌色是调性维度的切换(紫/蓝/绿/橙)——色彩系统、状态注入、持久化、验收方法全部复用。分开做两次重复劳动,合着做一次一劳永逸。这篇把骨架一次讲透:色彩系统怎么设计、主题状态怎么全局流转、系统深浅怎么跟随、应用内怎么切换、切换怎么即时生效且重启保留。
先破三个常见误解。「深色模式就是把颜色反转」——不是,反色出来的界面像底片,正经的深色盘是重新设计的(后文有三条军规);「换肤是 UI 库的事」——UI 库给你组件的默认色,你的业务界面里几十处自定义色值它管不着,换肤的架构得自己做;「先做完功能再说」——颜色散落各处写得越多,收敛的成本越高,色彩系统应该是界面动工前就定好的地基。这三个误解的共同代价是同一种结局:深色模式永远排在下个版本。而这篇要论证的是:做对骨架之后,深色模式和换肤是一次性的地基工程——地基打好,「加一种皮肤」是数组加一行的事,深色适配从「专项」降级成「例行」。

色彩系统:一把 accent 派生一餐盘
换肤做不下去的团队,问题几乎都出在起点:颜色是散落的。页面上几十个 #666666、#F5F5F5 散在各处,深色模式要翻倍、品牌色要换,逐个找出来改——改到一半就放弃了。「散落」还有个隐蔽的变体:定义了常量但常量名是色值直译(GRAY_66、BG_F5)——形式上收敛了,语义上还是散的,深色模式下 GRAY_66 该变浅、名字和用途对不上,改起来一样痛苦。语义化命名的检验标准很简单:把所有色值盖住,只看字段名能不能推断出「这个颜色用在哪、深浅切换时它该往哪边变」。正确的起点是把颜色收敛成一套有语义结构的调色板——字段名说用途,不说色值:
export interface Palette {
bg: string // 页面底色
card: string // 卡片/面板底色
text: string // 主文字
sub: string // 次文字/说明文字
accent: string // 品牌主色(按钮、进度、选中态)
accentSoft: string // 主色弱化背景(选中胶囊、次按钮底)
onAccent: string // 主色上的前景(按钮文字,恒白)
divider: string // 分割线
isDark: boolean // 当前是否深色盘(个别场景要用)
}
九个字段覆盖一个常规界面的全部用色。关键在于这些字段里只有一个「自由变量」:accent——品牌主色是产品定的(紫还是蓝),其余全部可以从「深色还是浅色」这一个问题派生出来:
export function buildPalette(mode: number, accent: string): Palette {
const dark: boolean = effectiveDark(mode)
const bg: string = dark ? '#101218' : '#F6F7FA'
const card: string = dark ? '#1B1E27' : '#FFFFFF'
const p: Palette = {
bg: bg,
card: card,
text: dark ? '#ECEEF4' : '#1A1D26',
sub: dark ? '#9AA0AE' : '#6B7280',
accent: accent,
accentSoft: mixHex(bg, accent, 0.14),
onAccent: '#FFFFFF',
divider: dark ? '#2A2E3A' : '#E8EAF0',
isDark: dark
}
return p
}
中性色(bg/card/text/sub/divider)按深浅两套写死——这些色值的设计感(深色 bg 不是纯黑而是 #101218 的「碳灰」、card 比 bg 亮一档)是设计师的工作,但对工程来说重要的是它们只有两套:深色一套浅色一套,不随品牌色变。品牌相关的只有 accent 和它的派生色 accentSoft。
这个「一个自由变量」的结构还有个经常被低估的收益:主题色可以随时加。产品哪天要加个「限定红」皮肤——往 ACCENTS 数组里加一行(key、名字、色值),四色变五色,全部控件自动支持,因为没有任何一处代码在关心「有几种颜色」这个数量。反过来,如果每种颜色都在各页面有配套的散落色值,加一色就是全工程搜索替换的重灾区。扩展成本是检验架构的最好尺子。
accentSoft 值得多说两句:选中态胶囊的底色、次按钮的底、标签的背景,这类「要带点主色但不能太浓」的颜色,是用混色算法现场算的——accent 往 bg 里掺 14%:
export function mixHex(base: string, top: string, ratio: number): string {
const b: number[] = hexToRgb(base)
const t: number[] = hexToRgb(top)
const r: number = b[0] * (1 - ratio) + t[0] * ratio
const g: number = b[1] * (1 - ratio) + t[1] * ratio
const bl: number = b[2] * (1 - ratio) + t[2] * ratio
return rgbToHex(r, g, bl)
}
**为什么不设计四个色的「软色」而要现场算?**因为「软」是相对的:紫色掺进白底和掺进深灰底,肉眼合适的观感比例一致但色值完全不同——按底色现算,浅色盘深色盘自动各自成立,换品牌色时也自动跟随。一套算法消灭了 4 色 × 2 模式 = 8 个手填色值,还杜绝了「换了 accent 忘了改软色」这种必然会发生的事故。
混色比例 14% 不是物理常数,是观感调校的起点——10% 太淡(和底色分不出层次)、20% 太浓(接近实心 accent)。调这个比例的时候有个小技巧:把 8%、14%、20% 三档同时铺在界面上(三行胶囊各用一档),眯起眼睛看哪档「存在但不抢」——色彩参数的标定永远靠并排对比,孤立地看任何一个色都是骗自己。
调色板是纯函数,深浅两套 × 四色 = 八个盘子的正确性可以离线全量断言(背景底色、文字对比、结构不变性——换 accent 只动 accent 系字段),demo 里十六个用例 node 跑绿,不用对着屏幕逐个切换人工验收。
「纯函数调色板」还有个不显眼的好处:色板可以被任何东西消费。demo 里消费它的是 ArkUI 组件,但通知的背景色(通知样式要跟当前主题)、卡片的消息色、分享出去的文案底色——只要能拿到 ThemeState 的地方都能算出一致的色板。色彩逻辑与 UI 框架解耦后,「系统给你一小块画布让你自己画」的场景(通知、卡片、小组件)也能吃到同一套视觉规范——这些场景恰恰是换肤最容易漏的角落:主应用换了深色、桌面卡片还是浅色,用户天天看着这个色差,等于换肤做了一半。
状态架构:全局主题的 V2 注入
色板是纯函数,但「当前是什么模式、什么 accent」是全局状态——每个页面都要读,设置页要写。全局状态的第一问永远是「几份真相」:理想是一份(单一数据源),所有读处共享;最糟是每页一份(各自为政),永远不知道哪份是对的。V2 体系里跨组件共享状态的正解是 @ObservedV2 类 + AppStorageV2——connect 按 key 拿全局唯一实例,天然一份真相:
@ObservedV2
export class ThemeState {
@Trace mode: number = MODE_FOLLOW
@Trace accentKey: string = 'violet'
palette(): Palette {
return buildPalette(this.mode, accentHex(this.accentKey))
}
}
页面侧连接全局实例:
@Entry
@ComponentV2
struct ThemeLabPage {
theme: ThemeState =
AppStorageV2.connect(ThemeState, 'app-theme', () => new ThemeState()) ?? new ThemeState()
@Local pal: Palette = this.theme.palette()
connect 按 key 取全局唯一实例(没有就建一个)——任何页面 connect 同一个 'app-theme',拿到的是同一个对象。写入侧改 theme.mode,@Trace 属性级追踪让所有读这个实例的地方感知到变化。connect 的第三个参数是默认构造器——全局没有实例时用它建一个,这也是「默认主题」在代码里的唯一落点(决策字段的初始值写在类定义上,别处在构造时覆盖)。这个不起眼的参数保证了「首次启动没有任何页面碰过主题时,everyone 拿到的是同一个默认实例」——而不是各自 new 出三个紫色的平行宇宙。
顺带回应一个 V1 老手的疑问:为什么不用 V1 的 AppStorage(AppStorage.setOrCreate('bg', '#fff') 一路散着存)?能用,但 V1 的 AppStorage 是字符串键的松散存取——键名拼错静默失效、值类型无约束(bg 存成了 number 也能过)、跨组件传的还是散值不是结构。V2 的 connect 拿到的是带类型的对象实例,编译器替你核对每一个字段的访问;换肤状态的演进(加字段、改语义)也在类定义一处收敛。V1 项目接入换肤可以先用 AppStorage 起步,但字段超过三四个就该升级到结构化的 V2 状态——散键的维护成本是超线性增长的。
这里有个分层细节值得停一下:ThemeState 存的是「决策」(模式枚举 + 色键),不存「色值」。页面消费的是 palette() 现算的结果,由页面在切换时刷新自己的 @Local pal。为什么不把 Palette 对象本身放全局 @Trace?两个原因:其一,Palette 是派生数据,把派生结果放进全局状态就出现了「两个真相源」(决策和结果都存,可能不一致);其二,@ObservedV2 的深度追踪对纯数据对象是浪费——色板不需要细粒度刷新,整份换掉即可。状态放决策,视图算派生,这个分层让换肤的状态管理保持在最小的复杂度。
另一种常见做法是工具类单例(ThemeUtil.getColor('bg'))——能用,但每个用色的地方都是主动拉取,UI 不会随状态变化自动刷新,得手动通知重绘。AppStorageV2 的注入式共享让「改一处、全页动」成为框架保证而不是调用纪律,这正是换肤这种「全局同时变」的场景最需要的性质。
和 PersistenceV2 的分工也顺带说清:AppStorageV2 是内存态的全局共享(进程活着就在),PersistenceV2 是持久化共享(自动落盘)。主题的两项决策(模式、色键)既要全局共享又要持久化——demo 的做法是 AppStorageV2 管共享、preferences 显式存取管持久(存取时机完全受控:切换时存、启动时读)。也可以直接用 PersistenceV2 一步到位(connect 即持久),代价是每次 @Trace 变更都触发落盘——两种都成立,团队按「落盘时机要不要控制」选。
模式三态与系统跟随
模式有三个值,语义必须分清:
export const MODE_FOLLOW: number = -1 // 跟随系统
export const MODE_DARK: number = 0
export const MODE_LIGHT: number = 1
为什么是三态而不是「深/浅」两态?因为「跟随系统」不是可以牺牲的省略项——用户在系统层已经表达过一次偏好,应用内再强制二选一等于逼用户重复决策。三态里「跟随」是默认值、「深/浅」是覆盖:默认尊重系统、需要时应用内改写,这是尊重用户偏好的标准结构(字体大小、动静模式这类系统级偏好都适用同一个三态骨架)。
三个值的顺序也别眼熟就改——DARK=0、LIGHT=1 恰好和「布尔直觉」(0 假 0 暗)相反的直觉容易搞混,任何地方判断深浅都用常量名(mode === MODE_DARK)而不是裸数字,裸 if (mode === 0) 三个月后没人知道 0 是深还是浅。这三个数字与系统 ConfigurationConstant.ColorMode 的枚举值对齐(NOT_SET=-1、DARK=0、LIGHT=1),这样应用内的三态可以直接喂给系统接口:
ctx.getApplicationContext().setColorMode(next)
setColorMode 管的是系统资源层的深浅:带上深浅限定符的资源(深色图、$r 颜色资源配 dark 目录)、系统组件的默认底色,会按设置的值切换。设成 NOT_SET 就是「跟随系统」——系统切深色,资源层跟着深。
于是完整的换肤是双轨制:系统资源层走 setColorMode(管资源限定符),自定义色板走 ThemeState(管我们代码里的颜色)。两轨由同一个三态值驱动,永远同步。用纯 #RRGGBB 写界面的应用只需要第二条轨(像 demo 这样);大量使用 $r 颜色资源和系统组件默认色的应用,第一条轨是主战场。多数真实应用两条轨都要——这也是为什么要把三态值与系统枚举对齐:一个值同时驱动两条轨,不存在「资源层深色、自定义色浅色」的撕裂态。
资源轨的具体做法补一段给没用过限定符的读者:资源目录分 base 和 dark(还有横竖屏、分辨率等限定符同理),同名资源在两个目录各放一份,$r('app.color.card') 在深色模式自动取 dark 目录那份。这套机制的美妙在于零代码切换——setColorMode 一调,所有资源引用自动换版本,不用任何手动刷新。而且资源轨的深浅判定和系统深色模式天然联动:设备开了深色、应用没强制模式时,资源层自动走 dark 目录——这也是「跟随系统」在纯资源应用里的默认行为,一行代码不用写。它的边界也在这里:只对「资源」生效,你代码里写的 ‘#FFFFFF’ 它管不着——所以两轨制不是选择题,是各自的覆盖面决定的。
两种轨的选择也有讲究:静态色(品牌色、固定的灰阶)适合资源限定符——一份 json 声明、编译期校验、零运行时开销;动态色(可换的 accent、按数据变的强调色)适合代码色板——运行时计算、随状态派生。真实项目里最常见的配方是:中性色(bg/card/text/divider)走资源限定符(它们静态且要跟系统组件默认色对齐),accent 系走代码色板(它们要支持运行时换肤)——正好一轨一性,各得其所。
跟随系统的深浅事件也有入口:应用上下文可以监听系统环境变化(on('environment')),系统深浅切换时回调里拿新的 colorMode 更新 ThemeState——这样「跟随系统」模式下,自定义色板也实时跟系统翻。完整实现三段:
// 1. 启动时读当前系统深浅(UIAbilityContext.config)
const conf: Configuration = uCtx.config
this.theme.sysDark = conf.colorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK
// 2. 注册监听('environment' 的回调是 EnvironmentCallback 类,不是裸箭头函数)
const callback: EnvironmentCallback = {
onConfigurationUpdated: (envCfg: Configuration): void => {
const nowDark: boolean =
envCfg.colorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK
this.theme.sysDark = nowDark
this.pal = this.theme.palette()
},
onMemoryLevel: (level: number): void => {
}
}
uCtx.getApplicationContext().on('environment', callback)
// 3. palette 的三态判定(ThemeState 里的 sysDark 就是跟随态的答案)
export function effectiveDark(mode: number, sysDark: boolean): boolean {
if (mode === MODE_DARK) {
return true
}
if (mode === MODE_LIGHT) {
return false
}
return sysDark // 跟随系统:系统说了算
}
实测路径:应用切「跟随系统」→ Home 回桌面 → 系统设置「显示和亮度」选「深色」→ 回到应用——整页已翻深色,日志里 onConfigurationUpdated 的 colorMode=0(DARK)清晰可见。两个实现细节值得记:其一,回调形状是 EnvironmentCallback 类不是箭头函数——直接传箭头函数编译不过,这是 API 设计在逼你实现完整接口(onMemoryLevel 空实现也要写);其二,当前深浅的初值从 UIAbilityContext.config 读,不是等第一次回调——用户深色系统里冷启动应用,首帧就该是深色盘,不能白一下再黑。另外配置回调不只深浅一个触发源(语言、字号变化也走这里),回调里按需过滤,别每个配置变化都重算色板。
切换与持久化
切换动作本身只有三步:
setMode(next: number): void {
this.mode = next
this.theme.mode = next // 全局状态(@Trace 通知所有读处)
this.pal = this.theme.palette() // 本页派生值刷新
ctx.getApplicationContext().setColorMode(next) // 系统资源层
saveTheme(ctx, next, this.accentKey) // 持久化
}
即时生效的机制在上一节已经齐了:@Trace 属性变更 → 所有连接方的 UI 更新。没有重启、没有白屏、没有「下一页才是新色」——这是检验换肤实现质量的第一直观标准:切完立刻看当前页,没变就是实现有问题。
「下一页才是新色」这个症状值得单独说,它是半吊子换肤的典型表现:颜色存在页面级 @Local 里、初始化时算一次,切换只改了全局值没通知到已存在的页面——新推入的页面读到新值,留在栈里的旧页面还是旧的。治法就是 demo 的结构:页面持有的 pal 在切换入口统一刷新(setMode/setAccent 里改完 ThemeState 顺手重算本页 pal),或者更进一步用 @Computed 把 pal 变成 ThemeState 的派生值,依赖链自动传播。症状在页面、病根在注入结构——和状态管理篇的老话是同一句。
持久化用 preferences 存两个键(mode、accentKey)。存「模式决策」而不是「当时深浅」是个细节:用户选了跟随系统,存的是 FOLLOW(-1)而不是当时的浅色——他半夜打开应用时系统已是深色,应用跟着深;如果存的是具体深浅,深色适配就被「锁死」在设置那一刻了。存决策、别存快照,这类「延迟解释」的存储原则在国际化(存语言偏好不存当时文案)、时区(存跟随不存偏移)里反复出现。启动时恢复的时机在页面 aboutToAppear:读 prefs → 写回 ThemeState → 算初始 palette。实测里重启应用后模式选中态和主题色都回来了(日志里 setColorMode 的调用记录 + 界面选中态的背景色双重确认)。「重启丢主题」是换肤实现的高发缺陷——用户每次打开都要重选一遍,等于没做。
恢复还有一个隐藏时序问题值得提醒:首帧颜色从哪来。aboutToAppear 里才读 prefs 意味着首帧用的是 ThemeState 的默认值(跟随系统 + 紫)——如果用户上次选的是「深色 + 绿」,他会看到首帧浅紫、下一帧深绿的闪变。治法有二:把恢复提前到 Ability 的 onCreate(loadContent 之前 prefs 已就位,页面首帧即正确),或者接受这一帧闪变(半数用户根本不会注意)。产品对「闪一下」的容忍度不同,但至少要知道闪变从哪来——它是恢复时机的问题,不是渲染的问题。把恢复提到 Ability onCreate 还有个连带收益: setColorMode 也在那时调用,系统资源层的深浅在首帧前就位,资源类内容(深色图、系统组件默认色)连闪都不闪——恢复做得越早,两层轨的首帧越干净。
展示区:把每个常见控件都过一遍
换肤的完整性要用「控件面」来验收——「主页面看着没问题」不等于完整,长尾页面里一只没换的深色弹窗照样穿帮。demo 的展示区刻意覆盖了最容易漏的几类:
- 卡片与分割线:card 底色 + divider,深浅反差最大的一对,漏了 divider 深色下会变成「亮线刺眼」。divider 是全部字段里最没存在感也最常被硬编码的一个——随手写的 ‘#EEEEEE’ 散落在各页面,深色下全变成亮灰线,是最典型的「翻车从最不起眼的颜色开始」;
- 主次按钮:主按钮 accent 底白字,次按钮 accentSoft 底 accent 字——两个层级都吃主题色;
- 进度与图表柱:数据可视化元素用纯 accent,深浅两盘下同一 accent 的观感一致性靠中性色的衬托,图表在深色盘里尤其要验证「够不够亮」。图表是换肤后最容易「掉档次」的控件——浅色盘里精心调的数据配色到深色盘里往往发灰,多序列图表(每个序列一个色)在深色盘里还可能撞色,可视化配色要么按深浅各调一套、要么限死序列数让 accent 系撑住;
- 标签胶囊:选中态 accentSoft 底 + 未选中态 card 底带 divider 描边——选中/未选变的对比在两盘下都要成立。选中态是换肤观感的「点睛处」:accentSoft 的混色比在这里直接受检——比例对了选中态像「被点亮」,错了要么和未选中分不清(太淡)、要么喧宾夺主(太浓);
- 输入框:bg 底 + divider 描边 + text 文字,键盘弹起后占位文字的颜色也走 sub。
这份清单的用法是验收清单:做完换肤,把应用里每类控件在「深色 × 四色」的八个组合下过一遍。经验里最容易翻车的是三处:输入框光标色(部分系统组件的光标跟 accent 不走,深色下可能看不见)、WebView 内嵌页(不跟原生色板,要单独传参刷新样式)、弹窗和 toast(用系统样式的容易漏)。三处的共同点是**「界面成分在你色板的管辖之外」**——自己的页面自己管,边界上的成分要单独登记。彻底的工程化做法是把这份控件清单做成「换肤回归页」(demo 的展示区就是雏形):每个新控件上线时往这个页面加一个样本,换肤改动后只跑这一页就能全量回归——把验收从「翻遍所有页面」变成「看一页」。这个页面平时藏在开发者菜单里,截图留档每一版深浅 × 各色的渲染结果,视觉回归就有了基线——纯靠肉眼比对上一版「有没有变丑」,两周之后没人记得住上一版长什么样。


!
对比度:换肤的安全底线
颜色换得动是及格线,换完读得清是底线。换肤的自由度越高(四色 × 深浅八个组合),「某组合下读不清」的概率就越大——四色里那个亮橙配白底按钮文字(onAccent 恒白)在浅色盘里就偏弱,这种隐患单靠人眼验收八个组合很难全覆盖。深色模式下最常见的翻车则是灰字配灰底——sub 文字 #9AA0AE 在深色 bg #101218 上的对比度约 7:1,安全;但随手把 sub 调暗一档到 #666666,对比度掉到 3.8:1,小字号下老花眼基本告别阅读。WCAG 的 AA 级要求正文对比度 ≥ 4.5:1,调色板定稿时把九个字段的文字类组合(text/bg、sub/bg、onAccent/accent)全部过一遍对比度计算——这同样是纯函数,能进 node 单测。
对比度测试用例和调色板断言写在一起,色彩系统的正确性就闭环了:**结构(字段齐)、一致性(换 accent 不动中性)、可读性(对比度达标)**三条全部离线验证。设计稿审色时人眼会累,断言不会。
深色不是反色:设计侧的三条军规
工程侧讲完了,三条设计侧的常识值得带给没做过深色模式的团队——它们决定了你的深色盘「像不像正经产品」:
**深色 bg 不是纯黑。**纯黑 (#000000) 配纯白文字是最高对比也最刺眼的组合,OLED 上还容易拖影。主流做法是碳灰系(#0D0F14 ~ #1A1D26 一档),卡片再比 bg 亮一档靠「面」的层次代替「线」的分割。选碳灰还有个省电的顺带——OLED 像素不完全熄灭,纯黑大面积切换时的亮度骤变更晃眼。一档「亮一档的卡片」是多少?8~12 个亮度点(#101218 的卡片配 #1B1E27 的底,差 11 点)是观感舒服的常见区间,太近分不出层次、太远像贴了张纸。
**饱和度要压。**浅色盘里鲜艳的 accent 到深色盘里要降饱和提亮——同一枚 #2F80ED 在白底上稳重、在碳灰底上发闷。demo 简化共用一个 accent,讲究的做法是 accent 也分深浅两枚(同色相、深色盘那枚亮一档),四色 × 两模式 = 八枚主色,仍然在「派生」的框架里——buildPalette 加一个 accentDarken 参数的事,结构不变。判断深色盘的 accent 够不够亮,看它在深色底上的「发光感」:够亮的 accent 像自发光的霓虹,发闷的像褪色塑料——这个差异直接决定按钮和进度条在深色模式下的品质感。
**阴影换层级。**浅色盘靠投影(elevation)区分层次,深色盘里阴影几乎不可见——改成描边(divider 色细边)或亮度差。组件库的深色适配里这是工作量最大的一块。
三条军规之外再补一条工程视角的:深色盘要单独过一遍「情绪」。浅色界面换到深色后,同样的布局可能显得更「重」——深色有视觉收拢感,浅色盘里合适的间距和密度到深色盘里可能变拥挤。大版本做深色适配时,设计师对深色版做一轮独立的密度微调(间距放大 5~10%),比照抄浅色布局的观感明显更好。这不是玄学,是深色背景对边界感知的真实影响。
总结
收拢这篇的骨架:一套调色板(一把 accent + 深浅两套中性色 + 混色派生软色)、一个全局状态(@ObservedV2 + AppStorageV2 注入决策而非色值)、一个三态值(对齐系统枚举,同时驱动 setColorMode 的资源轨和 ThemeState 的色板轨)、一套持久化与启动恢复。
带走的经验:
- 颜色先收敛成结构再谈换肤——九字段调色板 + 一个自由变量,散落的色值是换肤做不下去的根因;
- accentSoft 用混色现场算:浅深两盘自动成立,换色自动跟随,消灭手填软色值;
- 全局主题放决策不放派生结果,注入用 AppStorageV2 让「改一处全页动」成为框架保证;
- 三态值与系统枚举对齐,一个值驱动资源层和色板层双轨,杜绝撕裂;
- 验收按控件面 + 八组合(深浅 × 各 accent)过,输入框光标、系统弹窗、WebView 是三大漏点;把控件面固化成「换肤回归页」,验收从翻全应用变成看一页;
- 「搜不到散落色值」是硬标准——code review 对颜色字面量零容忍,比口头约定可靠;
- 对比度是安全底线,WCAG 4.5:1 进单测,别让深色模式变成可读性灾难;
- 深色不是反色:碳灰底、压饱和、阴影换描边——这三条决定深色盘的「正经感」;
- 持久化存决策不存快照(FOLLOW 而非当时的深浅),用户偏好延迟到使用那一刻再解释;
- 恢复时机决定首帧颜色,闪变的根源在 aboutToAppear 才读 prefs,不在渲染。
换肤是那种「看起来只是换颜色、做起来是全套架构」的典型课题——色彩系统、全局状态、系统能力、持久化四件事缺一不可。这四件事也各有各的「返工信号」:颜色散落是色彩系统没做、切完要重启是状态注入没做、系统深色不跟是双轨没对齐、重开丢设置是持久化没做——对照这四个信号给自己的应用做个快诊,哪个信号在哪个环节欠账,一目了然。
反过来,把它做扎实之后你会发现:后续任何「全局视觉规范」类需求(字体缩放、圆角风格、多品牌皮肤)都是同一套骨架换载荷——决策进全局状态、派生进视图、资源走限定符、切换即持久。 demo 里那套「三态 + 四色 + 调色板」的结构,把「皮肤」这个词从一堆散落的色值变成了一个受管理的配置维度——这就是骨架的全部意义:让变化有处可放。一次把骨架搭对,比每次都绕着走划算得多。
更多推荐



所有评论(0)