乒乓球口袋教练 HarmonyOS 学习应用(06):主题 Token 与球台视觉风格 一、视觉切换为什么不能直接改业务页面球台绿、记分卡白底和深色背景都属于视觉语义课程完成度、收藏和练习记录则属于业务事实。如果页面里散落着十几处颜色常量切换深色模式时最容易把文本、分隔线和阴影遗漏同时也诱发组件把视觉选择误当成业务状态。本项目把颜色收敛为 palette把主题模式收敛为 system、light、dark 三个值。// 2. 根据用户在设置页选择的 mode 写入对应 palette 到 AppStorage // 3. UI 用 StorageProp(palette) 拿色用 StorageProp(is_dark) 切图标 / 阴影 export class ThemeManager { private static instance: ThemeManager | null null; private listener: mediaquery.MediaQueryListener | null null; private systemDark: boolean false; private mode: ThemeMode system; static get(): ThemeManager { if (ThemeManager.instance null) { ThemeManager.instance new ThemeManager(); } return ThemeManager.instance; } // 应用启动时调用从持久化恢复 mode默认 system订阅系统主题 init(persistedMode: ThemeMode, systemColorMode?: ConfigurationConstant.ColorMode): void { this.mode persistedMode; AppStorage.setOrCreateThemeMode(KEY_THEME_MODE, this.mode); this.listener mediaquery.matchMediaSync((prefers-color-scheme: dark)); this.systemDark this.listener.matches; this.updateSystemColorMode(systemColorMode, false); this.refreshSystemDark(false);二、ThemeManager 如何确定当前调色板ThemeManager 初始化时读取持久化的 theme_mode并结合 mediaquery 监听系统颜色模式。它把 palette 与 is_dark 写进 AppStorage页面通过 StorageProp 读取同一套 Token。这样课程卡片、设置页和首页并不需要各自监听系统事件视觉更新仍能由一个管理器完成。参与者输入输出或约束模型或配置稳定标识、模式或模块字段给出可追溯的工程事实服务或系统能力经过归一化的请求返回明确结果或失败原因页面回读后的结果只渲染不保存第二份事实} setMode(mode: ThemeMode): void { this.mode mode; AppStorage.setOrCreateThemeMode(KEY_THEME_MODE, mode); if (mode system) { this.refreshSystemDark(false); } this.applyPalette(); } private resolveDark(): boolean { if (this.mode dark) return true; if (this.mode light) return false; return this.systemDark; } private applyPalette(): void { const isDark: boolean this.resolveDark(); const palette: ColorPalette isDark ? DarkPalette : LightPalette; AppStorage.setOrCreateboolean(KEY_IS_DARK, isDark); AppStorage.setOrCreateColorPalette(KEY_PALETTE, palette); }三、设置页只提交模式而不拼接颜色设置页的单选项只调用 ThemeManager.get().setMode(m.id)。setMode 先写入 theme_mode再依据模式调用 apply它不修改课程模型也不会清空正在输入的搜索词。这个边界很重要用户回到课程页看到的是同一份学习数据换了一套色彩而不是一次看起来像“刷新成功”的数据重置。.onClick(() { this.themeMode m.id; ThemeManager.get().setMode(m.id); PreferencesHelper.get().persist(theme_mode, m.id); }); if (i MODE_OPTIONS.length - 1) { Column().width(92%).height(0.5).backgroundColor(this.palette.divider); } }, (m: ModeOption) m.id); } .width(100%) .backgroundColor(this.palette.card) .borderRadius(AppSizes.r3) .clip(true); } .alignItems(HorizontalAlign.Start) .width(100%); } Builder fontSection() { Column({ space: AppSizes.s2 }) { Text(字体大小)四、系统主题变化与人工选择怎样区分system 模式与 dark 模式不能混为一谈。前者允许监听器随系统变化后者是用户明确锁定深色。当用户从 system 改为 light 后系统再次变暗不应覆盖其选择重新选回 system 才恢复监听。若不区分这两条路径设置页的显示值会和实际调色板发生错位。情况容易出现的错误本文采用的处理数据或配置缺项伪造默认成功状态停在可解释的失败或空态页面重进使用上一页残留对象从模型、服务或系统重新回读重复动作再写一遍相同业务事实由稳定入口或回调收敛} export const LightPalette: ColorPalette { primary: #0A84FF, primaryEnd: #5B5BF0, accent: #FF8A00, bg: #F5F7FA, bgElevated: #FFFFFF, card: #FFFFFF, cardOverlay: #00000010, divider: #EAECEF, textPrimary: #1A1A1F, textSecondary: #5A6072, textTertiary: #9097A5, textOnPrimary: #FFFFFF, success: #34C759, danger: #FF3B30, badgeBg: #E6F0FF, badgeText: #0A84FF, tabActive: #0A84FF, tabInactive: #9097A5, shadow: #0F1A3320, scrim: #00000066五、切换后的可读性如何回读验证先在设置页依次选择浅色、深色和跟随系统分别进入首页和课程卡片回读背景、主文字和辅助文字的对比再返回设置页确认选中项与主题一致最后重启应用检查 theme_mode 恢复而课程进度没有变化。配图展示的是深色设置状态不能替代对另两种模式的检查。验收阶段实际动作回读重点前置确认启动正确 bundle 或打开目标页标题、入口与模块身份主题操作执行搜索、切换、完成或跳转服务/系统返回的结果重进检查返回、重启或切换范围后再进入事实没有依赖旧页面残留六、实现边界与维护顺序深浅色切换只改主题模式和调色板 Token课程内容、进度与输入状态不随视觉模式发生第二份写入。 新增需求时应先补齐模型、配置或服务合同再调整页面入口把同一个判断复制到多个组件短期看似方便后续会使结果无法回读。ArkTS 状态管理的基础机制可参考 HarmonyOS 官方文档。七、继续扩展时的约束调色板的变化范围应当只包括表现层。课程模型、视频位置、练习日志和测验成绩即使被写入 AppStorage也不能和 theme_mode 使用同一个含义含混的键。主题状态越独立越能避免一次皮肤升级被误判为学习数据迁移。深色模式的验收要注意可读不是只有文字没有消失。辅助说明、时间戳、未解锁徽章、禁用按钮和卡片边线在低亮度背景上也要保持区分度。把这些元素的角色写进 palette可以让设计调整只影响 Token而不是让每一个页面临时增加深色分支。如果设备系统颜色变化发生在设置页之外页面重新出现时应订阅已经更新的 palette而不需要导航栈逐页重建。这个体验依赖 ThemeManager 的唯一实例因此模块初始化和销毁时要避免重复注册监听器。Token 不能被组件私自覆盖组件可以根据 is_dark 选择图标资源却不应在局部把文本颜色改成另一套固定色。局部覆盖一多设置页显示深色而课程页仍有浅色残留的问题就无法从 ThemeManager 追踪。需要新增视觉角色时应先扩展 palette 定义再让组件消费这个名字明确的角色。设置值的恢复顺序应用重新启动后先恢复 theme_mode再生成 palette最后让页面读取 StorageProp。若先渲染页面再异步补颜色用户会看到短暂的闪白若恢复失败则使用明确的 system 默认值并允许在设置页重新选择而不是留下半初始化的颜色对象。