乒乓球口袋教练 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.setOrCreate<ThemeMode>(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.setOrCreate<ThemeMode>(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.setOrCreate<boolean>(KEY_IS_DARK, isDark); AppStorage.setOrCreate<ColorPalette>(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 默认值并允许在设置页重新选择,而不是留下半初始化的颜色对象。